From c774259eb8c96b5619b257389074a6b4343f473d Mon Sep 17 00:00:00 2001 From: monarcode Date: Sat, 7 Nov 2026 21:32:14 +0100 Subject: [PATCH 1/4] feat: add statusErrors to map error responses to typed errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Map statuses to error schemas on the client, endpoints, definitions and single calls, tried call → endpoint → client - Add `client.statusErrors` and `statusErrors` on endpoints and definitions, plus `StatusErrorMap`, `StatusError`, `StatusErrorSchema` and `StatusErrorServices` - Add `examples/status-errors.ts` and wire the feature into `examples/app`, with README, CHANGELOG, tests and smoke checks --- CHANGELOG.md | 40 ++ README.md | 124 +++- examples/README.md | 34 +- .../app/infrastructure/http/app-api-client.ts | 7 +- examples/app/infrastructure/http/plugins.ts | 14 +- .../infrastructure/http/wallet-api-client.ts | 7 +- examples/app/main.ts | 4 + examples/app/modules/todos/contract/todos.ts | 18 +- examples/app/modules/todos/public.ts | 1 + .../app/modules/wallet/contract/wallet.ts | 13 + .../app/modules/wallet/data/wallet.api.ts | 8 +- examples/app/modules/wallet/public.ts | 1 + examples/status-errors.ts | 163 +++++ scripts/editor-check.mjs | 36 +- scripts/smoke.sh | 42 ++ src/Client.ts | 93 ++- src/Endpoint.ts | 78 ++- src/Options.ts | 239 +++++++- src/Plugin.ts | 2 +- src/index.ts | 4 + src/internal/endpoint.ts | 34 +- src/internal/json.ts | 7 + src/internal/request.ts | 14 +- src/internal/response.ts | 78 ++- src/internal/statusErrors.ts | 133 +++++ test/statusErrors.test.ts | 562 ++++++++++++++++++ test/types/statusErrors.test-d.ts | 444 ++++++++++++++ test/utils/client.ts | 3 +- 28 files changed, 2090 insertions(+), 113 deletions(-) create mode 100644 examples/status-errors.ts create mode 100644 src/internal/json.ts create mode 100644 src/internal/statusErrors.ts create mode 100644 test/statusErrors.test.ts create mode 100644 test/types/statusErrors.test-d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 994c563..8b74851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,46 @@ All notable changes to `@monarcode/effetch`, newest first. +## 0.1.0-rc.2 + +### Added + +- `statusErrors` turns error responses into your own errors. It maps an + HTTP status to an error schema, usually a `Schema.TaggedError` class, or + to a list of schemas tried in order: + `statusErrors: { 404: TodoNotFound, 400: [InvalidRole, RoleTaken] }`. + The first schema that decodes the JSON body is what the call fails with, + so callers write `Effect.catchTag("TodoNotFound", …)` rather than + checking `status` and `body`. It can be given to `Fetch.make` (every + call), `client.endpoint` and `Fetch.endpoint` (every call of the + endpoint) and a call's options. The call's schemas are tried first, then + the endpoint's, then the client's. + - Backends don't send a `_tag`, so effetch adds the schema's literal + `_tag` to the body before decoding. `Schema.encodeKeys` renames fields. + - A response that no schema decodes, or with an empty or non-JSON body, + fails with the `HttpError` it fails with today, which stays in the + error type. Calls without `statusErrors` keep exactly their types and + behaviour. + - Plugins see the raw response, as before, so `retry` still retries by + status. `onError` receives the decoded error. + - Maps are copied and frozen where they're given, so changing one later + changes nothing. The client has a `statusErrors` property, its copy or + `undefined`, and so do `Fetch.endpoint` definitions. Endpoints have a + `statusErrors` property too: their map joined with the client's, each + status listing its schemas in the order they're tried. + - A client's type includes its map, `Client`, so a client + with a map isn't assignable to a plain `Fetch.Client`, which would hide + its errors. + - New types: `StatusErrorMap`, `StatusError`, `StatusErrorSchema` and + `StatusErrorServices`. + `Client`, `ClientConfig`, `Endpoint`, `EndpointDefinition`, + `UnboundEndpoint`, `UnboundDefinition`, the option types, the method + signatures, `CallEffect`, `CallError` and `CallServices` gain a type + parameter for the maps, last and defaulting to `undefined`, so existing + type references keep compiling. Values typed as `Client`, `Endpoint` or + `UnboundEndpoint` that you build by hand, such as test doubles, need a + `statusErrors` property. + ## 0.1.0-rc.1 ### Added diff --git a/README.md b/README.md index 934b78a..98d6e79 100644 --- a/README.md +++ b/README.md @@ -494,7 +494,9 @@ export const archive = api.post("/todos/:id/archive", { ### What can go wrong A call can fail in three ways, each with its own tagged error. Plugins can add -their own, such as `timeout`'s `TimeoutError`. +their own, such as `timeout`'s `TimeoutError`, and +[`statusErrors`](#errors-by-status) can turn an `HttpError` into one of your +own. | Error | When | Fields | | ----------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------- | @@ -555,6 +557,124 @@ the raw body is always on `text`. `Fetch.HttpError.With` is the type of an `HttpError` whose `body` is `B`, for your own function signatures. +### Errors by status + +Often a status, or a status with a given body, means a specific error in +your app: a 404 from `GET /todos/:id` is "todo not found", a 400 with +`"code": "INVALID_ROLE"` is "invalid role", a 401 from anywhere is "session +expired". `statusErrors` maps statuses to your own error schemas, and the +call fails with your error instead of an `HttpError`: + +```ts +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as Fetch from "@monarcode/effetch"; + +class TodoNotFound extends Schema.TaggedError()("TodoNotFound", { + message: Schema.String, +}) {} + +class InvalidRole extends Schema.TaggedError()("InvalidRole", { + code: Schema.Literal("INVALID_ROLE"), + message: Schema.String, +}) {} + +class RoleTaken extends Schema.TaggedError()("RoleTaken", { + code: Schema.Literal("ROLE_TAKEN"), + message: Schema.String, +}) {} + +class SessionExpired extends Schema.TaggedError()( + "SessionExpired", + { message: Schema.String }, +) {} + +const ApiError = Schema.Struct({ code: Schema.String }); +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); +const User = Schema.Struct({ id: Schema.Finite, role: Schema.String }); + +export const getTodo = Fetch.endpoint("GET /todos/:id", { + output: Todo, + statusErrors: { 404: TodoNotFound }, +}); + +export const assignRole = Fetch.endpoint("POST /users/:id/roles", { + input: Schema.Struct({ role: Schema.String }), + output: User, + statusErrors: { 400: [InvalidRole, RoleTaken] }, // tried in order +}); + +export const program = Effect.gen(function* () { + const api = yield* Fetch.make({ + baseUrl: "https://api.example.com", + defaultError: ApiError, + statusErrors: { 401: SessionExpired }, // for every call + }); + const todo = yield* api + .bind(getTodo)({ params: { id: 1 } }) + .pipe(Effect.catchTag("TodoNotFound", () => Effect.succeed(undefined))); + const user = yield* api + .bind(assignRole)({ params: { id: 1 }, body: { role: "owner" } }) + .pipe( + Effect.catchTags({ + InvalidRole: (error) => Effect.fail(error.message), + RoleTaken: (error) => Effect.fail(error.message), + }), + ); + return { todo, user }; +}); +``` + +`assignRole`'s calls fail with +`InvalidRole | RoleTaken | SessionExpired | HttpError.With<{ readonly code: string }> | NetworkError | DecodeError`. +`HttpError` stays: a response can always fail to match. + +How a non-2xx response is resolved: + +1. If the body is empty or isn't JSON, it's an `HttpError`. +2. Otherwise the schemas listed for its status are tried in order: the + call's, then the endpoint's, then the client's. The first one that + decodes the body is the error. +3. If none does, it's the `HttpError` you'd get without `statusErrors`, its + `body` decoded with `error` or `defaultError`. + +| Response | Fails with | +| ------------------------------------------------ | ---------------------------- | +| 400 `{ "code": "INVALID_ROLE", "message": "…" }` | `InvalidRole` | +| 400 `{ "code": "ROLE_TAKEN", "message": "…" }` | `RoleTaken` | +| 400 `{ "code": "MISSING_FIELD" }` | `HttpError` with `body.code` | +| 400 with an empty or non-JSON body | `HttpError` | +| 401 from any endpoint | `SessionExpired` | +| 500 | `HttpError` | + +A few details: + +- **The `_tag`.** Backends don't send one, so when a schema has a literal + `_tag`, as `Schema.TaggedError` classes do, effetch adds it to the body + before decoding. A body that has its own `_tag` is decoded as it is. +- **Renamed fields.** To read `error_code` into `code`, use + `Schema.encodeKeys`: + `InvalidRole.pipe(Schema.encodeKeys({ code: "error_code" }))`. +- **Lists, not unions.** A `Schema.Union` is decoded as it is, without a + `_tag`: list its members instead. +- **2xx entries never apply**, since a 2xx is decoded with `output`. +- Plugins see the response before any of this, so `retry` still retries by + status. `onError` receives your error, and `api.httpClient` stays raw. +- Maps are copied when they're given, so changing one afterwards changes + nothing. `api.statusErrors` and a `Fetch.endpoint` definition's + `statusErrors` are those copies, frozen; an endpoint's `statusErrors` + joins its own with the client's, each status listing its schemas in the + order they're tried. +- [`examples/status-errors.ts`](examples/status-errors.ts) puts all of this + in one program, and [`examples/app`](examples/app) uses it across modules. +- `Fetch.StatusError` is the union of the errors in a map `M`, and + `Fetch.StatusErrorSchema` the union of its schemas, for your own + function signatures. +- A client's type includes its map: `Fetch.Client`. A client + with a map can't be passed where a plain `Fetch.Client` is expected, + since that would hide its errors. + ### Safe to log Errors are made to be logged and reported: @@ -1413,6 +1533,8 @@ they always match the current API: endpoints. - [`backends.ts`](examples/backends.ts): two backends in one file, with shared plugins. +- [`status-errors.ts`](examples/status-errors.ts): your own errors by status, + for every call, per endpoint and for one call. - [`app/`](examples/app): a feature-sliced app, with clients as shared infrastructure and each module defining its own endpoints. diff --git a/examples/README.md b/examples/README.md index 9bc7dd4..bf483b5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,14 +3,15 @@ Complete programs using effetch. They're typechecked with the package (`pnpm run typecheck`), so they always match the current API. -| Example | What it shows | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| [`basic.ts`](basic.ts) | A client in a service, the methods (`get`, `post`, `patch`, `del`) and handling each error. | -| [`builtins.ts`](builtins.ts) | The built-in plugins (`logger`, `retry`, `timeout`, `bearer`) and per-call overrides. | -| [`plugins.ts`](plugins.ts) | Writing plugins: auth with a service, a header with options, a `wrap` with its own error, error reporting. | -| [`endpoints.ts`](endpoints.ts) | The same app as `builtins.ts` with endpoints: one client, its endpoints as the service's methods. | -| [`backends.ts`](backends.ts) | Two backends in one file: one client each, shared plugins, and each backend's errors caught next to its calls. | -| [`app/`](app) | A feature-sliced app: clients as shared infrastructure, each module defining its own endpoints next to its schemas. | +| Example | What it shows | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| [`basic.ts`](basic.ts) | A client in a service, the methods (`get`, `post`, `patch`, `del`) and handling each error. | +| [`builtins.ts`](builtins.ts) | The built-in plugins (`logger`, `retry`, `timeout`, `bearer`) and per-call overrides. | +| [`plugins.ts`](plugins.ts) | Writing plugins: auth with a service, a header with options, a `wrap` with its own error, error reporting. | +| [`endpoints.ts`](endpoints.ts) | The same app as `builtins.ts` with endpoints: one client, its endpoints as the service's methods. | +| [`backends.ts`](backends.ts) | Two backends in one file: one client each, shared plugins, and each backend's errors caught next to its calls. | +| [`status-errors.ts`](status-errors.ts) | Your own errors by status: client-wide, per endpoint, a 400 told apart by its `code`, a renamed field, a one-off call. | +| [`app/`](app) | A feature-sliced app: clients as shared infrastructure, each module defining its own endpoints next to its schemas. | `endpoints.ts` and `backends.ts` keep everything in one file, which suits a small app. `app/` is a layout for an app with many endpoints and modules. @@ -31,20 +32,20 @@ it builds: `makeApi` builds `Api`, `makeAppApiClient` builds `AppApiClient`, app/ ├── infrastructure/ │ └── http/ -│ ├── plugins.ts shared plugins: auth, error reporting +│ ├── plugins.ts shared plugins and errors: auth, SessionExpired │ ├── app-api-client.ts AppApiClient: the main backend's client │ └── wallet-api-client.ts WalletApiClient: a second backend's client ├── modules/ │ ├── todos/ │ │ ├── contract/ -│ │ │ └── todos.ts schemas and endpoints: Todo, getTodo, … +│ │ │ └── todos.ts schemas, errors, endpoints: Todo, TodoNotFound, getTodo, … │ │ ├── data/ │ │ │ └── todo.api.ts TodoApi: the endpoints, bound to a client │ │ ├── todo.layer.ts the module's live layers │ │ └── public.ts what other code may import │ └── wallet/ │ ├── contract/ -│ │ └── wallet.ts schemas: Balance, Transfer, Receipt +│ │ └── wallet.ts schemas and errors: Balance, Transfer, TransferLimitExceeded │ ├── data/ │ │ └── wallet.api.ts WalletApi: the module's endpoints │ ├── application/ @@ -60,9 +61,10 @@ Read it in that order: a client, a module, the runtime, then `main.ts`. ### The rules it follows 1. **A client file says how to reach a backend, and nothing else:** its base - URL, the body of its errors and its plugins. It holds no endpoints and no - module schemas, and never imports a module, so it stays the same size - however many endpoints the app has. + URL, the body of its errors, what a status means from every endpoint (its + `statusErrors`, such as `401: SessionExpired`) and its plugins. It holds + no endpoints and no module schemas, and never imports a module, so it + stays the same size however many endpoints the app has. 2. **A module's endpoints sit next to its schemas, and its `data/` folder connects them to the client it talks to.** `todos` defines them in `contract/todos.ts` with `Fetch.endpoint`, which needs no client, and @@ -70,7 +72,9 @@ Read it in that order: a client, a module, the runtime, then `main.ts`. with `client.endpoint`, importing the schemas from `contract/`. Both give the same endpoints; use the first when something should read the routes and schemas without a client. Either way, dependencies point one way: - module → infrastructure, never back. + module → infrastructure, never back. An endpoint's own `statusErrors` + (`404: TodoNotFound`) sit with it, and the module's `public.ts` exports + those errors, so other code can catch them. 3. **Endpoints are bound or made once, when the module's layer is built,** never in code that runs per request. 4. **Every module's data service is its endpoints** (`TodoApi`, diff --git a/examples/app/infrastructure/http/app-api-client.ts b/examples/app/infrastructure/http/app-api-client.ts index 7adacbd..b41d9b5 100644 --- a/examples/app/infrastructure/http/app-api-client.ts +++ b/examples/app/infrastructure/http/app-api-client.ts @@ -7,11 +7,13 @@ import * as Schema from "effect/Schema"; import * as Fetch from "@monarcode/effetch"; import { logger, retry } from "@monarcode/effetch/plugins"; -import { auth, reportErrors } from "./plugins.js"; +import { auth, reportErrors, SessionExpired } from "./plugins.js"; // The app's main backend: how to reach it, and nothing else. No endpoints // and no module schemas live here, so this file stays the same size however -// many endpoints the modules define, and it never imports a module. +// many endpoints the modules define, and it never imports a module. What a +// status means from every endpoint, such as a 401, belongs here; what it +// means from one endpoint belongs with that endpoint. /** The body of this backend's non-2xx responses. */ export const AppApiError = Schema.Struct({ @@ -22,6 +24,7 @@ export const AppApiError = Schema.Struct({ const makeAppApiClient = Fetch.make({ baseUrl: Config.String("APP_API_URL"), defaultError: AppApiError, + statusErrors: { 401: SessionExpired }, plugins: [logger(), reportErrors("app"), retry(), auth], }); diff --git a/examples/app/infrastructure/http/plugins.ts b/examples/app/infrastructure/http/plugins.ts index e36b4c5..93817d4 100644 --- a/examples/app/infrastructure/http/plugins.ts +++ b/examples/app/infrastructure/http/plugins.ts @@ -1,10 +1,11 @@ import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as Fetch from "@monarcode/effetch"; import { bearer } from "@monarcode/effetch/plugins"; -// Plugins shared by the app's clients. Plugins are plain values, so one -// `auth` serves every backend that takes the same token. +// Plugins and errors shared by the app's clients. Plugins are plain values, +// so one `auth` serves every backend that takes the same token. export const session: { accessToken: string | undefined } = { accessToken: undefined, @@ -12,6 +13,15 @@ export const session: { accessToken: string | undefined } = { export const auth = bearer({ token: () => session.accessToken }); +/** + * What a 401 means from any of the app's backends: the token is no longer + * valid. Each client turns it into this error with `statusErrors`. + */ +export class SessionExpired extends Schema.TaggedError()( + "SessionExpired", + { message: Schema.String }, +) {} + // `ctx.route` is a method and a path, with no host: two backends can both // have a `GET /health`. A plugin that groups by route takes the backend's // name, and each client gets its own instance. diff --git a/examples/app/infrastructure/http/wallet-api-client.ts b/examples/app/infrastructure/http/wallet-api-client.ts index fbcd18f..b64dea3 100644 --- a/examples/app/infrastructure/http/wallet-api-client.ts +++ b/examples/app/infrastructure/http/wallet-api-client.ts @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema"; import * as Fetch from "@monarcode/effetch"; import { logger, timeout } from "@monarcode/effetch/plugins"; -import { auth, reportErrors } from "./plugins.js"; +import { auth, reportErrors, SessionExpired } from "./plugins.js"; // A second backend, with its own base URL, error body and plugins: one // client per backend, each in its own service. @@ -21,6 +21,11 @@ export const WalletApiError = Schema.Struct({ const makeWalletApiClient = Fetch.make({ baseUrl: Config.String("WALLET_API_URL"), defaultError: WalletApiError, + // The shared error, read from this backend's body: its message is + // `reason`. + statusErrors: { + 401: SessionExpired.pipe(Schema.encodeKeys({ message: "reason" })), + }, plugins: [ logger(), reportErrors("wallet"), diff --git a/examples/app/main.ts b/examples/app/main.ts index fb89879..4684132 100644 --- a/examples/app/main.ts +++ b/examples/app/main.ts @@ -23,6 +23,10 @@ const payRent = Effect.gen(function* () { Effect.catchTags({ InsufficientFunds: (error) => Effect.fail(`Only ${error.balance} left, ${error.amount} needed`), + TransferLimitExceeded: (error) => + Effect.fail(`Transfer refused: ${error.reason}`), + TodoNotFound: () => Effect.fail("The task was deleted meanwhile"), + SessionExpired: () => Effect.fail("Please sign in again"), HttpError: (error) => Effect.fail(`HTTP ${error.status} from ${error.url}`), TimeoutError: (error) => diff --git a/examples/app/modules/todos/contract/todos.ts b/examples/app/modules/todos/contract/todos.ts index f2622d7..24a6198 100644 --- a/examples/app/modules/todos/contract/todos.ts +++ b/examples/app/modules/todos/contract/todos.ts @@ -30,8 +30,19 @@ export type NewTodo = typeof NewTodo.Type; export const TodoPatch = Schema.Struct({ done: Schema.Boolean }); export type TodoPatch = typeof TodoPatch.Type; +/** A 404 from an endpoint that takes a todo's id. */ +export class TodoNotFound extends Schema.TaggedError()( + "TodoNotFound", + { message: Schema.String }, +) {} + +const byId = { 404: TodoNotFound }; + export const listTodos = Fetch.endpoint("GET /todos", { output: TodoPage }); -export const getTodo = Fetch.endpoint("GET /todos/:id", { output: Todo }); +export const getTodo = Fetch.endpoint("GET /todos/:id", { + output: Todo, + statusErrors: byId, +}); export const createTodo = Fetch.endpoint("POST /todos", { input: NewTodo, output: Todo, @@ -40,5 +51,8 @@ export const updateTodo = Fetch.endpoint("PATCH /todos/:id", { input: TodoPatch, output: Todo, headers: { "content-type": "application/merge-patch+json" }, + statusErrors: byId, +}); +export const removeTodo = Fetch.endpoint("DELETE /todos/:id", { + statusErrors: byId, }); -export const removeTodo = Fetch.endpoint("DELETE /todos/:id"); diff --git a/examples/app/modules/todos/public.ts b/examples/app/modules/todos/public.ts index 7569f60..0053c81 100644 --- a/examples/app/modules/todos/public.ts +++ b/examples/app/modules/todos/public.ts @@ -1,3 +1,4 @@ // What other code may use from the module. export type { NewTodo, Todo, TodoPage, TodoPatch } from "./contract/todos.js"; +export { TodoNotFound } from "./contract/todos.js"; export { TodoApi } from "./data/todo.api.js"; diff --git a/examples/app/modules/wallet/contract/wallet.ts b/examples/app/modules/wallet/contract/wallet.ts index c5ef2d3..5077e8c 100644 --- a/examples/app/modules/wallet/contract/wallet.ts +++ b/examples/app/modules/wallet/contract/wallet.ts @@ -15,3 +15,16 @@ export type Transfer = typeof Transfer.Type; export const Receipt = Schema.Struct({ id: Schema.String }); export type Receipt = typeof Receipt.Type; + +/** + * A 422 from `POST /transfers` with `"error": "LIMIT_EXCEEDED"`. The wallet + * backend names its code `error`; `encodeKeys` reads it into `code`. + */ +export class TransferLimitExceeded extends Schema.TaggedError()( + "TransferLimitExceeded", + { code: Schema.Literal("LIMIT_EXCEEDED"), reason: Schema.String }, +) {} + +export const TransferLimitExceededBody = TransferLimitExceeded.pipe( + Schema.encodeKeys({ code: "error" }), +); diff --git a/examples/app/modules/wallet/data/wallet.api.ts b/examples/app/modules/wallet/data/wallet.api.ts index 9685342..d78a85c 100644 --- a/examples/app/modules/wallet/data/wallet.api.ts +++ b/examples/app/modules/wallet/data/wallet.api.ts @@ -3,7 +3,12 @@ import type * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { WalletApiClient } from "../../../infrastructure/http/wallet-api-client.js"; -import { Balance, Receipt, Transfer } from "../contract/wallet.js"; +import { + Balance, + Receipt, + Transfer, + TransferLimitExceededBody, +} from "../contract/wallet.js"; // Same shape as `todos`: the module's endpoints are its data service, made // from the client of the backend the module talks to. Operations that take @@ -16,6 +21,7 @@ const makeWalletApi = WalletApiClient.useSync((client) => ({ transfer: client.endpoint("POST /transfers", { input: Transfer, output: Receipt, + statusErrors: { 422: TransferLimitExceededBody }, plugins: { timeout: { duration: "30 seconds" } }, }), })); diff --git a/examples/app/modules/wallet/public.ts b/examples/app/modules/wallet/public.ts index 3d84802..0259151 100644 --- a/examples/app/modules/wallet/public.ts +++ b/examples/app/modules/wallet/public.ts @@ -1,4 +1,5 @@ // What other code may use from the module. export { InsufficientFunds, sendMoney } from "./application/send-money.js"; export type { Balance, Receipt, Transfer } from "./contract/wallet.js"; +export { TransferLimitExceeded } from "./contract/wallet.js"; export { WalletApi } from "./data/wallet.api.js"; diff --git a/examples/status-errors.ts b/examples/status-errors.ts new file mode 100644 index 0000000..026343a --- /dev/null +++ b/examples/status-errors.ts @@ -0,0 +1,163 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; + +import * as Fetch from "@monarcode/effetch"; + +// The backend's error body, for responses that aren't one of the errors +// below. +const ApiError = Schema.Struct({ code: Schema.String, message: Schema.String }); + +// The app's own errors. The backend never sends a `_tag`: effetch adds it +// before decoding, so plain `Schema.TaggedError` classes work. + +/** A 401 from any endpoint: the session is over. */ +export class SessionExpired extends Schema.TaggedError()( + "SessionExpired", + { message: Schema.String }, +) {} + +/** A 429 from any endpoint. */ +export class RateLimited extends Schema.TaggedError()( + "RateLimited", + { retryAfter: Schema.Finite }, +) {} + +/** A 404 from the todo endpoints. */ +export class TodoNotFound extends Schema.TaggedError()( + "TodoNotFound", + { message: Schema.String }, +) {} + +// Two 400s from `POST /todos/:id/assignees`, told apart by their literal +// `code`. Any other 400 stays an `HttpError`. + +export class UnknownUser extends Schema.TaggedError()( + "UnknownUser", + { code: Schema.Literal("UNKNOWN_USER"), message: Schema.String }, +) {} + +export class AlreadyAssigned extends Schema.TaggedError()( + "AlreadyAssigned", + { + code: Schema.Literal("ALREADY_ASSIGNED"), + message: Schema.String, + assignee: Schema.String, + }, +) {} + +/** A 409 from `GET /reports/:id`, while the report is being built. */ +export class ReportNotReady extends Schema.TaggedError()( + "ReportNotReady", + { message: Schema.String }, +) {} + +const Todo = Schema.Struct({ + id: Schema.Finite, + title: Schema.String, + assignees: Schema.Array(Schema.String), +}); + +const Report = Schema.Struct({ url: Schema.String }); + +const makeApi = Effect.gen(function* () { + const api = yield* Fetch.make({ + baseUrl: "https://api.example.com", + defaultError: ApiError, + // What these statuses mean from any endpoint. The backend sends + // `retry_after_seconds`; `encodeKeys` reads it into `retryAfter`. + statusErrors: { + 401: SessionExpired, + 429: RateLimited.pipe( + Schema.encodeKeys({ retryAfter: "retry_after_seconds" }), + ), + }, + }); + return { + getTodo: api.endpoint("GET /todos/:id", { + output: Todo, + statusErrors: { 404: TodoNotFound }, + }), + assign: api.endpoint("POST /todos/:id/assignees", { + input: Schema.Struct({ userId: Schema.String }), + output: Todo, + // A list is tried in order: the first that decodes the body wins. + statusErrors: { + 404: TodoNotFound, + 400: [UnknownUser, AlreadyAssigned], + }, + }), + // A one-off call, with a map of its own. + report: (id: number) => + api.get("/reports/:id", { + params: { id }, + output: Report, + statusErrors: { 409: ReportNotReady }, + }), + }; +}); + +export class Api extends Context.Service>()( + "Api", +) {} + +export const ApiLive = Layer.effect(Api)(makeApi).pipe( + Layer.provide(FetchHttpClient.layer), +); + +// A missing todo is `undefined`; every other error stays in the type, for +// the caller to handle. +export const findTodo = (id: number) => + Effect.gen(function* () { + const { getTodo } = yield* Api; + return yield* getTodo({ params: { id } }); + }).pipe(Effect.catchTag("TodoNotFound", () => Effect.undefined)); + +// Each error comes with its own fields: `assignee` is typed and decoded. +export const assignTo = (id: number, userId: string) => + Effect.gen(function* () { + const { assign } = yield* Api; + return yield* assign({ params: { id }, body: { userId } }); + }).pipe( + Effect.catchTags({ + AlreadyAssigned: (error) => + Effect.fail(`Already assigned to ${error.assignee}`), + UnknownUser: (error) => Effect.fail(error.message), + }), + ); + +export const program = Effect.gen(function* () { + const todo = yield* findTodo(1); + if (todo === undefined) { + return "No todo 1"; + } + yield* assignTo(todo.id, "u_ada"); + const { report } = yield* Api; + const { url } = yield* report(todo.id).pipe( + Effect.retry({ + while: (error) => error._tag === "ReportNotReady", + schedule: Schedule.spaced("2 seconds"), + times: 3, + }), + ); + return url; +}).pipe( + Effect.catchTags({ + SessionExpired: () => Effect.fail("Please sign in again"), + RateLimited: (error) => + Effect.fail(`Too many requests: try again in ${error.retryAfter}s`), + ReportNotReady: () => Effect.fail("The report isn't ready yet"), + TodoNotFound: (error) => Effect.fail(error.message), + // Whatever the maps don't cover is still an HttpError. + HttpError: (error) => + Effect.fail(error.body?.message ?? `HTTP ${error.status}`), + NetworkError: () => Effect.fail("You appear to be offline"), + DecodeError: (error) => + Effect.fail(`Unexpected response: ${error.message}`), + }), +); + +export const main = program.pipe(Effect.provide(ApiLive)); diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index 544f013..5d46a6c 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -71,6 +71,29 @@ export const /*servicesCall*/servicesCall = typed.post("/x", { body: "x", output: Localized, }); + +class TodoNotFound extends Schema.TaggedError()("TodoNotFound", { + message: Schema.String, +}) {} +class SessionExpired extends Schema.TaggedError()("SessionExpired", { + message: Schema.String, +}) {} +declare const mapped: Fetch.Client<[], typeof ApiError, { 401: typeof SessionExpired }>; +export const /*statusGet*/statusGet = typed.get("/todos/:id", { + params: { id: 1 }, + output: Todo, + statusErrors: { 404: TodoNotFound }, +}); +const getMapped = mapped.endpoint("GET /todos/:id", { + output: Todo, + statusErrors: { 404: TodoNotFound }, +}); +export const /*statusEndpoint*/statusEndpoint = getMapped({ params: { id: 1 } }); +const definedMapped = Fetch.endpoint("GET /todos/:id", { + output: Todo, + statusErrors: { 404: TodoNotFound }, +}); +export const /*statusBound*/statusBound = mapped.bind(definedMapped)({ params: { id: 1 } }); `; const config = ts.getParsedCommandLineOfConfigFile( @@ -285,6 +308,17 @@ for (const [marker, errors, services] of [ ["requestCall", callErrors, ["never"]], ["timedCall", [...callErrors, "TimeoutError"].sort(), ["never"]], ["servicesCall", callErrors, ["Locale", "Signer"]], + ["statusGet", [...callErrors, "TodoNotFound"].sort(), ["never"]], + [ + "statusEndpoint", + [...callErrors, "SessionExpired", "TodoNotFound"].sort(), + ["never"], + ], + [ + "statusBound", + [...callErrors, "SessionExpired", "TodoNotFound"].sort(), + ["never"], + ], ]) { const hover = ts.displayPartsToString( service.getQuickInfoAtPosition(probe, at(marker))?.displayParts, @@ -319,7 +353,7 @@ const queryHover = ts.displayPartsToString( service.getQuickInfoAtPosition(probe, at("query"))?.displayParts, ); expect( - queryHover.includes(", ListUsersParams>") && !queryHover.includes("QueryOf"), + /, ListUsersParams[,>]/.test(queryHover) && !queryHover.includes("QueryOf"), `Hover on get with a query doesn't name the query's type: ${queryHover}`, ); diff --git a/scripts/smoke.sh b/scripts/smoke.sh index e517334..754b077 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -278,6 +278,48 @@ if ( throw new Error(`Unexpected bound request: ${seen.at(-1)}`); } +// An error response turned into the app's own error by statusErrors. +class TodoNotFound extends Schema.TaggedError()("TodoNotFound", { + message: Schema.String, +}) {} + +const notFound = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ message: "No todo 9" }), { + status: 404, + headers: { "content-type": "application/json" }, + }), + ), + ), +); + +const getMissing = Fetch.endpoint("GET /todos/:id", { + output: Todo, + statusErrors: { 404: TodoNotFound }, +}); +if (getMissing.statusErrors[404] !== TodoNotFound) { + throw new Error("Unexpected definition statusErrors"); +} + +const statusProgram = Effect.gen(function* () { + const api = yield* Fetch.make({ baseUrl: "https://api.test" }); + const bound = api.bind(getMissing); + if (bound.statusErrors[404]?.[0] !== TodoNotFound) { + throw new Error("Unexpected endpoint statusErrors"); + } + return yield* bound({ params: { id: 9 } }).pipe( + Effect.catchTag("TodoNotFound", (error) => Effect.succeed(error.message)), + ); +}).pipe(Effect.provideService(HttpClient.HttpClient, notFound)); + +const missingTodo: string | { readonly id: number } = + await Effect.runPromise(statusProgram); +if (missingTodo !== "No todo 9") { + throw new Error(`Unexpected statusErrors result: ${JSON.stringify(missingTodo)}`); +} + console.log("smoke: ok"); TS diff --git a/src/Client.ts b/src/Client.ts index 1431dda..2c88475 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -20,12 +20,14 @@ import { provideRoute, } from "./internal/plugins.js"; import { makeCall, type RuntimeOptions } from "./internal/request.js"; +import { freezeStatusErrors } from "./internal/statusErrors.js"; import { hasQueryOrFragment } from "./internal/url.js"; import type { AnySchema, BodyMethod, NoBodyMethod, RequestMethod, + StatusErrorMap, } from "./Options.js"; import type { Plugin } from "./Plugin.js"; @@ -44,6 +46,9 @@ import type { Plugin } from "./Plugin.js"; * header is set here or on the call. * - `defaultError`: the schema for non-2xx response bodies of calls that * don't pass their own `error` option. A call's `error` replaces it. + * - `statusErrors`: turns error responses into your own errors, by status, + * for every call (see {@link StatusErrorMap}). An endpoint's or a call's + * own `statusErrors` are tried first. * - `plugins`: hooks into every call (see {@link Plugin}). `plugins[0]` is * the outermost layer: its `onRequest` runs first and its `onResponse` * last. Names must be unique. Pass the list inline: annotating it as @@ -80,6 +85,7 @@ export interface ClientConfig< EB extends AnySchema = Schema.Unknown, BaseE = never, BaseR = never, + SE extends StatusErrorMap | undefined = undefined, > { /** Prepended to relative paths. A string, or an Effect run once by `make`. */ readonly baseUrl?: string | Effect.Effect | undefined; @@ -87,17 +93,25 @@ export interface ClientConfig< readonly headers?: Headers.Input | undefined; /** Schema for non-2xx response bodies, unless a call passes its own `error`. */ readonly defaultError?: EB | undefined; + /** Your own errors for error responses, by status, for every call. */ + // No `| undefined`, unlike the other options: `SE` already allows it, and + // with it `statusErrors: undefined` infers the constraint, which widens + // the call's errors to `unknown`. + readonly statusErrors?: SE; /** Plugins, outermost first. Names must be unique. */ readonly plugins?: Ps | undefined; } /** * A client made by {@link make}. Every call returns an Effect that needs - * no services, apart from those the call's own schemas require, and that - * can fail with the plugins' errors on top of effetch's own. + * no services, apart from those its schemas require (the call's or + * endpoint's own, and the client's `defaultError` and `statusErrors`), and + * that can fail with the plugins' errors and the `statusErrors` that apply, + * on top of effetch's own. * * - `Ps`: the plugins the client was made with. * - `EB`: the client's `defaultError` schema. + * - `SE`: the client's `statusErrors` map, or `undefined` without one. * * @example * ```ts @@ -120,12 +134,13 @@ export interface ClientConfig< export interface Client< Ps extends ReadonlyArray = [], EB extends AnySchema = Schema.Unknown, + SE extends StatusErrorMap | undefined = undefined, > { /** * The configured `HttpClient` underneath. It adds the base URL and the * default headers to the requests you give it and runs every plugin - * with its defaults, but does no status check, no decoding and no - * `onError` hooks. + * with its defaults, but does no status check, no decoding, no + * `statusErrors` and no `onError` hooks. * * Transport failures are `NetworkError`s, next to the plugins' own * errors. Use it for anything the methods don't cover, such as @@ -150,6 +165,31 @@ export interface Client< NetworkError | Plugin.Errors >; + /** + * The `statusErrors` the client was made with, as a frozen copy, or + * `undefined` without any. They apply to every call, after the call's + * or the endpoint's own. + * + * @example + * ```ts + * import * as Schema from "effect/Schema"; + * import type * as Fetch from "@monarcode/effetch"; + * + * class SessionExpired extends Schema.TaggedError()( + * "SessionExpired", + * { message: Schema.String }, + * ) {} + * + * declare const api: Fetch.Client< + * [], + * Schema.Unknown, + * { 401: typeof SessionExpired } + * >; + * api.statusErrors[401]; // SessionExpired + * ``` + */ + readonly statusErrors: SE; + /** * Sends a request with any HTTP method. The other methods are shorthand * for this one. @@ -169,7 +209,7 @@ export interface Client< * }); * ``` */ - readonly request: RequestMethod; + readonly request: RequestMethod; /** * Sends a `GET` request. Takes no body. @@ -192,7 +232,7 @@ export interface Client< * api.get("/todos/:id", { params: { id }, output: Todo }); * ``` */ - readonly get: NoBodyMethod; + readonly get: NoBodyMethod; /** * Sends a `HEAD` request. Takes no body. @@ -209,7 +249,7 @@ export interface Client< * ); * ``` */ - readonly head: NoBodyMethod; + readonly head: NoBodyMethod; /** * Sends an `OPTIONS` request. Takes no body. @@ -223,7 +263,7 @@ export interface Client< * Effect.map(api.options("/todos"), (response) => response.headers["allow"]); * ``` */ - readonly options: NoBodyMethod; + readonly options: NoBodyMethod; /** * Sends a `POST` request. `body` is sent according to its type, or @@ -251,7 +291,7 @@ export interface Client< * }; * ``` */ - readonly post: BodyMethod; + readonly post: BodyMethod; /** * Sends a `PUT` request. `body` is sent according to its type, or @@ -273,7 +313,7 @@ export interface Client< * }); * ``` */ - readonly put: BodyMethod; + readonly put: BodyMethod; /** * Sends a `PATCH` request. `body` is sent according to its type, or @@ -294,7 +334,7 @@ export interface Client< * }); * ``` */ - readonly patch: BodyMethod; + readonly patch: BodyMethod; /** * Sends a `DELETE` request. Named `del` because `delete` is a reserved @@ -308,7 +348,7 @@ export interface Client< * api.del("/todos/:id", { params: { id } }); * ``` */ - readonly del: BodyMethod; + readonly del: BodyMethod; /** * Defines an endpoint: a route like `"GET /users/:id"` and the schemas, @@ -368,7 +408,7 @@ export interface Client< * }); * ``` */ - readonly endpoint: EndpointMethod; + readonly endpoint: EndpointMethod; /** * Binds an endpoint defined with `Fetch.endpoint` to this client. You get @@ -377,7 +417,8 @@ export interface Client< * is `api.endpoint("GET /users/:id", { output: User })`. * * - Without its own `error`, the endpoint uses this client's - * `defaultError`. + * `defaultError`. This client's `statusErrors` are tried after the + * definition's. * - `plugins` overrides are given here, checked against this client's * plugins. A call's own override wins. * - `headers` given here are sent over the definition's. A call's own @@ -421,7 +462,7 @@ export interface Client< * }); * ``` */ - readonly bind: BindMethod; + readonly bind: BindMethod; } /** @@ -431,15 +472,16 @@ export interface Client< * `FetchHttpClient.layer`, or a mock in tests), whatever the `baseUrl` * Effect needs, and the services the plugins need. The client holds on to * all of them, so its calls need nothing from the environment apart from - * the services of their own schemas. + * the services of the schemas they decode and encode with. * * Calls run with the services and references `make` had, plus those of * the calling fiber, which win: a reference provided around one call (such * as `FetchHttpClient.RequestInit`) overrides the one `make` saw. * * Calls fail with `NetworkError` when no response arrives, `HttpError` for - * a non-2xx status, `DecodeError` when `output` doesn't match, and with - * the plugins' own errors. + * a non-2xx status, `DecodeError` when `output` doesn't match, with the + * plugins' own errors, and with your own errors where `statusErrors` turn a + * status into one. * * Invalid input is a defect, not a typed error, because it's a mistake in * the calling code: two plugins with the same name, a missing path param, @@ -497,10 +539,11 @@ export const make = < EB extends AnySchema = Schema.Unknown, BaseE = never, BaseR = never, + SE extends StatusErrorMap | undefined = undefined, >( - config: ClientConfig = {}, + config: ClientConfig = {}, ): Effect.Effect< - Client, + Client, BaseE, HttpClient.HttpClient | BaseR | Plugin.Services > => @@ -539,8 +582,11 @@ export const make = < ), applyDefaults, ); + const statusErrors = freezeStatusErrors(config.statusErrors); + const defaults = { defaultError: config.defaultError, statusErrors }; const call = makeCall({ errorSchema: config.defaultError, + statusErrors, applyDefaults, chain, plugins, @@ -548,6 +594,7 @@ export const make = < }); const client = { httpClient, + statusErrors, request: ( method: HttpMethod.HttpMethod, path: string, @@ -561,11 +608,11 @@ export const make = < patch: call("PATCH"), del: call("DELETE"), endpoint: (route: string, definition?: RuntimeDefinition) => - makeEndpoint(call, config.defaultError, route, definition), + makeEndpoint(call, defaults, route, definition), bind: ( definition: RuntimeUnboundEndpoint, options?: RuntimeBindOptions, - ) => bindEndpoint(call, config.defaultError, definition, options), + ) => bindEndpoint(call, defaults, definition, options), }; - return client as unknown as Client; + return client as unknown as Client; }); diff --git a/src/Endpoint.ts b/src/Endpoint.ts index 315f093..b12cbc9 100644 --- a/src/Endpoint.ts +++ b/src/Endpoint.ts @@ -13,6 +13,10 @@ import type { ParamsOption, QueryInput, QueryOf, + StatusError, + StatusErrorMap, + StatusErrorSchema, + StatusErrorServices, Success, } from "./Options.js"; import type { Plugin } from "./Plugin.js"; @@ -107,6 +111,8 @@ type NoBodyRoute = [RouteMethod] extends [ * with the raw `HttpClientResponse`. * - `error`: decodes non-2xx bodies into `HttpError.body`, replacing the * client's `defaultError`. + * - `statusErrors`: turns error responses into your own errors, by status + * (see {@link StatusErrorMap}). Tried before the client's. * - `input`: encodes `body` before it's sent as JSON, and makes `body` * required on every call. Not allowed on `GET`, `HEAD`, `OPTIONS` and * `TRACE` endpoints, which take no body. @@ -121,6 +127,7 @@ type NoBodyRoute = [RouteMethod] extends [ * - `O`, `EB`, `I`: the `output`, `error` and `input` schemas (`O` and `I` * are `undefined` when absent). * - `Ps`: the client's plugins. + * - `SE`: the `statusErrors` map, or `undefined` without one. * * @example * ```ts @@ -152,11 +159,17 @@ export interface EndpointDefinition< EB extends AnySchema, I extends AnySchema | undefined, Ps extends ReadonlyArray, + SE extends StatusErrorMap | undefined = undefined, > { /** Decodes the 2xx JSON body. Without it, calls return the raw response. */ readonly output?: O; /** Decodes non-2xx bodies into `HttpError.body`. Overrides the client's. */ readonly error?: EB | undefined; + /** Your own errors for error responses, by status. Tried before the client's. */ + // No `| undefined`, unlike the other options: `SE` already allows it, and + // with it `statusErrors: undefined` infers the constraint, which widens + // the call's errors to `unknown`. + readonly statusErrors?: SE; /** Encodes `body` as JSON, and makes `body` required on every call. */ readonly input?: NoBodyRoute extends true ? undefined : I; /** Headers for every call, over the client's. A call's own header wins. */ @@ -260,13 +273,16 @@ type Requirements< * Effect as the equivalent client method. * * It also carries its `route`, `method` and `path`, for things like log - * labels and cache keys, and its `output`, `error` and `input` schemas, for - * things like decoding cached data or building test fixtures. + * labels and cache keys, and its `output`, `error`, `statusErrors` and + * `input` schemas, for things like decoding cached data or building test + * fixtures. * * - `R`: the route, such as `"GET /users/:id"`. * - `O`, `EB`, `I`: the `output`, `error` and `input` schemas (`O` and `I` * are `undefined` when absent). * - `Ps`: the client's plugins. + * - `SE`: the `statusErrors` maps of the endpoint and the client, as a + * union, or `undefined` without any. * * @example * ```ts @@ -291,6 +307,7 @@ export interface Endpoint< EB extends AnySchema, I extends AnySchema | undefined, Ps extends ReadonlyArray, + SE extends StatusErrorMap | undefined = undefined, > { = QueryInput>( ...args: EndpointArgs> @@ -300,10 +317,12 @@ export interface Endpoint< | NetworkError | HttpError.With | (O extends AnySchema ? DecodeError : never) - | Plugin.Errors, + | Plugin.Errors + | StatusError, | (O extends AnySchema ? O["DecodingServices"] : never) | EB["DecodingServices"] | (I extends AnySchema ? I["EncodingServices"] : never) + | StatusErrorServices >; /** The route the endpoint was defined with, such as `"GET /users/:id"`. */ readonly route: R; @@ -318,6 +337,14 @@ export interface Endpoint< * else the client's `defaultError`, or else `Schema.Unknown`. */ readonly error: EB; + /** + * The schemas error responses are tried against, by status: for each + * status, the endpoint's `statusErrors`, then the client's, in the order + * they're tried. Empty when neither has any. + */ + readonly statusErrors: { + readonly [status: number]: ReadonlyArray>; + }; /** The schema the body is encoded with, or `undefined` without `input`. */ readonly input: I; } @@ -329,6 +356,7 @@ export interface Endpoint< * - `Ps`: the client's plugins. * - `DefaultEB`: the client's `defaultError` schema, used when the * endpoint has no `error`. + * - `ClientSE`: the client's `statusErrors` map, or `undefined` without one. * * @example * ```ts @@ -346,16 +374,18 @@ export interface Endpoint< export interface EndpointMethod< Ps extends ReadonlyArray, DefaultEB extends AnySchema, + ClientSE extends StatusErrorMap | undefined = undefined, > { < const R extends string, O extends AnySchema | undefined = undefined, EB extends AnySchema = DefaultEB, I extends AnySchema | undefined = undefined, + SE extends StatusErrorMap | undefined = undefined, >( route: ValidRoute, - definition?: EndpointDefinition, - ): Endpoint; + definition?: EndpointDefinition, + ): Endpoint; } /** @@ -366,6 +396,9 @@ export interface EndpointMethod< * with the raw `HttpClientResponse`. * - `error`: decodes non-2xx bodies into `HttpError.body`. Without it, the * endpoint uses the `defaultError` of the client it's bound to. + * - `statusErrors`: turns error responses into your own errors, by status + * (see {@link StatusErrorMap}). Tried before those of the client it's + * bound to. * - `input`: encodes `body` before it's sent as JSON, and makes `body` * required on every call. Not allowed on `GET`, `HEAD`, `OPTIONS` and * `TRACE` endpoints, which take no body. @@ -379,6 +412,7 @@ export interface EndpointMethod< * - `R`: the route, for the method and the path. * - `O`, `EB`, `I`: the `output`, `error` and `input` schemas (`undefined` * when absent). + * - `SE`: the `statusErrors` map, or `undefined` without one. * * @example * ```ts @@ -407,11 +441,17 @@ export interface UnboundDefinition< O extends AnySchema | undefined, EB extends AnySchema | undefined, I extends AnySchema | undefined, + SE extends StatusErrorMap | undefined = undefined, > { /** Decodes the 2xx JSON body. Without it, calls return the raw response. */ readonly output?: O; /** Decodes non-2xx bodies into `HttpError.body`. Without it, the client's `defaultError` does. */ readonly error?: EB; + /** Your own errors for error responses, by status. Tried before the client's. */ + // No `| undefined`, unlike the other options: `SE` already allows it, and + // with it `statusErrors: undefined` infers the constraint, which widens + // the call's errors to `unknown`. + readonly statusErrors?: SE; /** Encodes `body` as JSON, and makes `body` required on every call. */ readonly input?: NoBodyRoute extends true ? undefined : I; /** Headers for every call, over the client's. Binding and call headers win. */ @@ -432,6 +472,7 @@ export interface UnboundDefinition< * - `O`, `EB`, `I`: the `output`, `error` and `input` schemas (`undefined` * when absent; without `error`, the bound endpoint uses the client's * `defaultError`). + * - `SE`: the `statusErrors` map, or `undefined` without one. * * @example * ```ts @@ -455,6 +496,7 @@ export interface UnboundEndpoint< O extends AnySchema | undefined, EB extends AnySchema | undefined, I extends AnySchema | undefined, + SE extends StatusErrorMap | undefined = undefined, > { /** The route the endpoint was defined with, such as `"GET /users/:id"`. */ readonly route: R; @@ -469,6 +511,11 @@ export interface UnboundEndpoint< * `defaultError` of the client it's bound to. */ readonly error: EB; + /** + * The `statusErrors` map, as given, or `undefined` without one. Once + * bound, the client's are tried after it. + */ + readonly statusErrors: SE; /** The schema the body is encoded with, or `undefined` without `input`. */ readonly input: I; /** The headers sent with every call, or `undefined` without any. */ @@ -514,6 +561,7 @@ export interface BindOptions> { * - `Ps`: the client's plugins. * - `DefaultEB`: the client's `defaultError` schema, used when the * definition has no `error`. + * - `ClientSE`: the client's `statusErrors` map, or `undefined` without one. * * @example * ```ts @@ -531,16 +579,25 @@ export interface BindOptions> { export interface BindMethod< Ps extends ReadonlyArray, DefaultEB extends AnySchema, + ClientSE extends StatusErrorMap | undefined = undefined, > { < R extends string, O extends AnySchema | undefined, EB extends AnySchema | undefined, I extends AnySchema | undefined, + SE extends StatusErrorMap | undefined, >( - definition: UnboundEndpoint, + definition: UnboundEndpoint, options?: BindOptions, - ): Endpoint; + ): Endpoint< + R, + O, + EB extends AnySchema ? EB : DefaultEB, + I, + Ps, + SE | ClientSE + >; } /** @@ -557,6 +614,8 @@ export interface BindMethod< * it. `GET`, `HEAD`, `OPTIONS` and `TRACE` endpoints take no `input`. * - Without `error`, the endpoint uses the `defaultError` of the client * it's bound to. + * - `statusErrors` (see {@link StatusErrorMap}) are tried before those of + * the client it's bound to. * - Plugin overrides are given when binding: a definition doesn't know * which plugins a client has. * @@ -602,10 +661,11 @@ export function endpoint< O extends AnySchema | undefined = undefined, EB extends AnySchema | undefined = undefined, I extends AnySchema | undefined = undefined, + SE extends StatusErrorMap | undefined = undefined, >( route: ValidRoute, - definition?: UnboundDefinition, -): UnboundEndpoint; + definition?: UnboundDefinition, +): UnboundEndpoint; export function endpoint( route: string, definition?: RuntimeUnboundDefinition, diff --git a/src/Options.ts b/src/Options.ts index 0cd0148..dac26ff 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -93,6 +93,163 @@ export type QueryOf = { */ export type AnySchema = Schema.Constraint; +/** + * The `statusErrors` option: turns error responses into your own errors. + * Each key is an HTTP status, and its value is the schema of the error that + * status means, or a list of schemas tried in order. The first schema that + * decodes the response's JSON body is what the call fails with, in place of + * an `HttpError`. + * + * Usually the schemas are `Schema.TaggedError` classes. Backends don't send + * a `_tag`, so when a schema has a literal `_tag`, effetch adds it to the + * body before decoding. A literal field, such as + * `code: Schema.Literal("INVALID_ROLE")`, is how one 400 becomes your error + * while other 400s don't. + * + * A response that no schema decodes, or whose body is empty or isn't JSON, + * fails with the `HttpError` it fails with today. Entries for 2xx statuses + * never apply: a 2xx is decoded with `output`, and neither do keys that + * aren't statuses. A `Schema.Union` is decoded as it is, without a `_tag` + * added: list its members instead. + * + * Declare a map inline, with a type literal, or with + * `satisfies Fetch.StatusErrorMap`. A map typed with an `interface` isn't + * accepted: TypeScript gives interfaces no index signature. + * + * A client, an endpoint and a call can each have a map. For a given status, + * the call's schemas are tried first, then the endpoint's, then the + * client's. + * + * @example + * ```ts + * import * as Schema from "effect/Schema"; + * import type * as Fetch from "@monarcode/effetch"; + * + * class TodoNotFound extends Schema.TaggedError()("TodoNotFound", { + * message: Schema.String, + * }) {} + * class InvalidRole extends Schema.TaggedError()("InvalidRole", { + * code: Schema.Literal("INVALID_ROLE"), + * message: Schema.String, + * }) {} + * class RoleTaken extends Schema.TaggedError()("RoleTaken", { + * code: Schema.Literal("ROLE_TAKEN"), + * message: Schema.String, + * }) {} + * + * const statusErrors = { + * 404: TodoNotFound, + * 400: [InvalidRole, RoleTaken], + * } satisfies Fetch.StatusErrorMap; + * ``` + * + * @since 0.1.0 + */ +export type StatusErrorMap = { + readonly [status: number]: AnySchema | ReadonlyArray; +}; + +/** The `Type` of a `StatusErrorMap` entry's schemas. */ +type EntryType = + E extends ReadonlyArray + ? S["Type"] + : E extends AnySchema + ? E["Type"] + : never; + +/** The schemas of a `StatusErrorMap` entry. */ +type EntrySchema = + E extends ReadonlyArray + ? S + : E extends AnySchema + ? E + : never; + +/** + * The schemas a {@link StatusErrorMap} lists, as a union, under its numeric + * keys only. `M` can be a union of maps, and `undefined` gives `never`. + * + * @example + * ```ts + * import * as Schema from "effect/Schema"; + * import type * as Fetch from "@monarcode/effetch"; + * + * class TodoNotFound extends Schema.TaggedError()("TodoNotFound", { + * message: Schema.String, + * }) {} + * + * type S = Fetch.StatusErrorSchema<{ 404: typeof TodoNotFound }>; + * // ^? typeof TodoNotFound + * ``` + * + * @since 0.1.0 + */ +export type StatusErrorSchema = + M extends StatusErrorMap ? EntrySchema]> : never; + +/** The keys of `M` that are statuses: numbers, or numeric strings. */ +type StatusKey = Extract; + +/** The decoding services of a `StatusErrorMap` entry's schemas. */ +type EntryServices = + E extends ReadonlyArray + ? S["DecodingServices"] + : E extends AnySchema + ? E["DecodingServices"] + : never; + +/** + * The errors a {@link StatusErrorMap} can fail a call with: the union of its + * schemas' types. `M` can be a union of maps (the call's, the endpoint's and + * the client's), and is `undefined` when there are none, which gives + * `never`. + * + * @example + * ```ts + * import * as Schema from "effect/Schema"; + * import type * as Fetch from "@monarcode/effetch"; + * + * class TodoNotFound extends Schema.TaggedError()("TodoNotFound", { + * message: Schema.String, + * }) {} + * class SessionExpired extends Schema.TaggedError()("SessionExpired", { + * message: Schema.String, + * }) {} + * + * type E = Fetch.StatusError< + * { 404: typeof TodoNotFound } | { 401: typeof SessionExpired } + * >; + * // ^? TodoNotFound | SessionExpired + * ``` + * + * @since 0.1.0 + */ +export type StatusError = + M extends StatusErrorMap ? EntryType]> : never; + +/** + * The services decoding a {@link StatusErrorMap}'s errors requires: the + * union of its schemas' `DecodingServices`. Like {@link StatusError}, `M` + * can be a union of maps, and `undefined` gives `never`. + * + * @example + * ```ts + * import * as Schema from "effect/Schema"; + * import type * as Fetch from "@monarcode/effetch"; + * + * class TodoNotFound extends Schema.TaggedError()("TodoNotFound", { + * message: Schema.String, + * }) {} + * + * type R = Fetch.StatusErrorServices<{ 404: typeof TodoNotFound }>; + * // ^? never + * ``` + * + * @since 0.1.0 + */ +export type StatusErrorServices = + M extends StatusErrorMap ? EntryServices]> : never; + /** * The `params` option for path `P`: required (with exactly the path's * parameter names) when the path has `:params`, forbidden otherwise. @@ -128,6 +285,7 @@ export type ParamsOption

= string extends P * - `Ps`: the client's plugins, for the `plugins` override map. * - `Q`: the type of `query`, checked with {@link QueryOf}. Defaults to * {@link QueryInput}. + * - `SE`: the call's `statusErrors` map, or `undefined` without one. * * @example * ```ts @@ -150,6 +308,7 @@ export interface BaseOptions< EB extends AnySchema, Ps extends ReadonlyArray, Q extends QueryOf = QueryInput, + SE extends StatusErrorMap | undefined = undefined, > { /** Query parameters. Arrays repeat the key; `null`/`undefined` are dropped. */ readonly query?: Q | undefined; @@ -169,6 +328,15 @@ export interface BaseOptions< readonly output?: O; /** Decodes non-2xx bodies into `HttpError.body`. Overrides the client's. */ readonly error?: EB | undefined; + /** + * Turns error responses into your own errors, by status (see + * {@link StatusErrorMap}). Tried before the client's. A response none of + * them decodes fails with an `HttpError`, its body decoded with `error`. + */ + // No `| undefined`, unlike the other options: `SE` already allows it, and + // with it `statusErrors: undefined` infers the constraint, which widens + // the call's errors to `unknown`. + readonly statusErrors?: SE; /** * Per-request plugin overrides, by plugin name: `false` skips the * plugin's hooks and `wrap` for this call, and a partial of its options @@ -213,6 +381,7 @@ export interface BodyOptions { /** * Options of methods that don't send a body: `get`, `head`, `options`. + * The type parameters are those of {@link BaseOptions}. * * @example * ```ts @@ -232,15 +401,17 @@ export type NoBodyRequestOptions< EB extends AnySchema, Ps extends ReadonlyArray, Q extends QueryOf = QueryInput, + SE extends StatusErrorMap | undefined = undefined, > = ParamsOption

& - BaseOptions & { + BaseOptions & { readonly body?: undefined; readonly input?: undefined; }; /** * Options of methods that send a body: `post`, `put`, `patch`, `del` and - * `request`. + * `request`. The type parameters are those of {@link BaseOptions}, and `I` + * is the `input` schema. * * @example * ```ts @@ -261,7 +432,8 @@ export type RequestOptions< I extends AnySchema, Ps extends ReadonlyArray, Q extends QueryOf = QueryInput, -> = ParamsOption

& BaseOptions & BodyOptions; + SE extends StatusErrorMap | undefined = undefined, +> = ParamsOption

& BaseOptions & BodyOptions; /** * The rest arguments after the path: the options object `T` is required @@ -305,7 +477,11 @@ export type Success = O extends AnySchema /** * What a call can fail with: `NetworkError`, `HttpError` (with `body` * typed by the `error` schema), `DecodeError` when there's an `output` - * schema, and the plugins' errors. + * schema, the plugins' errors, and the errors of the `statusErrors` maps + * that apply (see {@link StatusError}). + * + * - `SE`: the `statusErrors` maps of the call, the endpoint and the client, + * as a union. `undefined` (the default) when there are none. * * @example * ```ts @@ -326,15 +502,20 @@ export type CallError< O extends AnySchema | undefined, EB extends AnySchema, Ps extends ReadonlyArray, + SE extends StatusErrorMap | undefined = undefined, > = | NetworkError | HttpError.With | (O extends AnySchema ? DecodeError : never) - | Plugin.Errors; + | Plugin.Errors + | StatusError; /** - * The services a call requires: those needed to decode `output` and - * `error` and to encode `input`. + * The services a call requires: those needed to decode `output`, `error` + * and the `statusErrors` maps' errors, and to encode `input`. + * + * - `SE`: the `statusErrors` maps of the call, the endpoint and the client, + * as a union. `undefined` (the default) when there are none. * * @example * ```ts @@ -355,10 +536,12 @@ export type CallServices< O extends AnySchema | undefined, EB extends AnySchema, I extends AnySchema | undefined, + SE extends StatusErrorMap | undefined = undefined, > = | (O extends AnySchema ? O["DecodingServices"] : never) | EB["DecodingServices"] - | (I extends AnySchema ? I["EncodingServices"] : never); + | (I extends AnySchema ? I["EncodingServices"] : never) + | StatusErrorServices; /** * The Effect a call returns: it succeeds with {@link Success}, fails with @@ -369,6 +552,8 @@ export type CallServices< * - `I`: the `input` schema (`Schema.Unknown` or `undefined` when the call * has none). * - `Ps`: the client's plugins. + * - `SE`: the `statusErrors` maps of the call, the endpoint and the client, + * as a union. `undefined` (the default) when there are none. * * Use it to type functions that return a call. To take a call as an * argument, accept any `Effect.Effect`: TypeScript can't infer @@ -395,6 +580,7 @@ export type CallEffect< EB extends AnySchema, I extends AnySchema | undefined, Ps extends ReadonlyArray, + SE extends StatusErrorMap | undefined = undefined, > = Effect.Effect< Success, // CallError and CallServices, written out: through the aliases, editors @@ -403,10 +589,12 @@ export type CallEffect< | NetworkError | HttpError.With | (O extends AnySchema ? DecodeError : never) - | Plugin.Errors, + | Plugin.Errors + | StatusError, | (O extends AnySchema ? O["DecodingServices"] : never) | EB["DecodingServices"] | (I extends AnySchema ? I["EncodingServices"] : never) + | StatusErrorServices >; /** @@ -415,6 +603,7 @@ export type CallEffect< * - `Ps`: the client's plugins. * - `DefaultEB`: the client's `defaultError` schema, used when a call has * no `error`. + * - `ClientSE`: the client's `statusErrors` map, or `undefined` without one. * * @example * ```ts @@ -432,24 +621,30 @@ export type CallEffect< export interface NoBodyMethod< Ps extends ReadonlyArray, DefaultEB extends AnySchema, + ClientSE extends StatusErrorMap | undefined = undefined, > { < P extends string, O extends AnySchema | undefined = undefined, EB extends AnySchema = DefaultEB, Q extends QueryOf = QueryInput, + SE extends StatusErrorMap | undefined = undefined, >( path: P, - ...args: Args> + ...args: Args> ): Effect.Effect< Success, // CallError and CallServices, written out (see CallEffect) | NetworkError | HttpError.With | (O extends AnySchema ? DecodeError : never) - | Plugin.Errors, + | Plugin.Errors + | StatusError> + | StatusError, | (O extends AnySchema ? O["DecodingServices"] : never) | EB["DecodingServices"] + | StatusErrorServices> + | StatusErrorServices >; } @@ -460,6 +655,7 @@ export interface NoBodyMethod< * - `Ps`: the client's plugins. * - `DefaultEB`: the client's `defaultError` schema, used when a call has * no `error`. + * - `ClientSE`: the client's `statusErrors` map, or `undefined` without one. * * @example * ```ts @@ -481,6 +677,7 @@ export interface NoBodyMethod< export interface BodyMethod< Ps extends ReadonlyArray, DefaultEB extends AnySchema, + ClientSE extends StatusErrorMap | undefined = undefined, > { < P extends string, @@ -488,19 +685,24 @@ export interface BodyMethod< EB extends AnySchema = DefaultEB, I extends AnySchema = Schema.Unknown, Q extends QueryOf = QueryInput, + SE extends StatusErrorMap | undefined = undefined, >( path: P, - ...args: Args> + ...args: Args> ): Effect.Effect< Success, // CallError and CallServices, written out (see CallEffect) | NetworkError | HttpError.With | (O extends AnySchema ? DecodeError : never) - | Plugin.Errors, + | Plugin.Errors + | StatusError> + | StatusError, | (O extends AnySchema ? O["DecodingServices"] : never) | EB["DecodingServices"] | (I extends AnySchema ? I["EncodingServices"] : never) + | StatusErrorServices> + | StatusErrorServices >; } @@ -511,6 +713,7 @@ export interface BodyMethod< * - `Ps`: the client's plugins. * - `DefaultEB`: the client's `defaultError` schema, used when a call has * no `error`. + * - `ClientSE`: the client's `statusErrors` map, or `undefined` without one. * * @example * ```ts @@ -532,6 +735,7 @@ export interface BodyMethod< export interface RequestMethod< Ps extends ReadonlyArray, DefaultEB extends AnySchema, + ClientSE extends StatusErrorMap | undefined = undefined, > { < P extends string, @@ -539,19 +743,24 @@ export interface RequestMethod< EB extends AnySchema = DefaultEB, I extends AnySchema = Schema.Unknown, Q extends QueryOf = QueryInput, + SE extends StatusErrorMap | undefined = undefined, >( method: HttpMethod.HttpMethod, path: P, - ...args: Args> + ...args: Args> ): Effect.Effect< Success, // CallError and CallServices, written out (see CallEffect) | NetworkError | HttpError.With | (O extends AnySchema ? DecodeError : never) - | Plugin.Errors, + | Plugin.Errors + | StatusError> + | StatusError, | (O extends AnySchema ? O["DecodingServices"] : never) | EB["DecodingServices"] | (I extends AnySchema ? I["EncodingServices"] : never) + | StatusErrorServices> + | StatusErrorServices >; } diff --git a/src/Plugin.ts b/src/Plugin.ts index ef2ebc0..3c258fe 100644 --- a/src/Plugin.ts +++ b/src/Plugin.ts @@ -215,7 +215,7 @@ export declare namespace Plugin { /** * The `onError` hook. `error` is a `FetchError`, another plugin's error, - * or a defect's cause. It can't fail or change the call's outcome: if it + * one of the call's `statusErrors`, or a defect's cause. It can't fail or change the call's outcome: if it * dies, the defect is logged at Debug level and ignored. * * Hooks run in array order, after the status check and decoding. They diff --git a/src/index.ts b/src/index.ts index 7613b1e..7e002f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,10 @@ export type { QueryValue, RequestMethod, RequestOptions, + StatusError, + StatusErrorMap, + StatusErrorSchema, + StatusErrorServices, Success, } from "./Options.js"; export type { PathParamNames, PathParams, PathParamValue } from "./Path.js"; diff --git a/src/internal/endpoint.ts b/src/internal/endpoint.ts index 385923c..46ebf89 100644 --- a/src/internal/endpoint.ts +++ b/src/internal/endpoint.ts @@ -2,20 +2,28 @@ import * as Headers from "effect/http/Headers"; import * as HttpMethod from "effect/http/HttpMethod"; import * as Schema from "effect/Schema"; -import type { AnySchema } from "../Options.js"; +import type { AnySchema, StatusErrorMap } from "../Options.js"; import { mergeOverrides, type OverrideMap } from "./plugins.js"; import type { Call, RuntimeOptions } from "./request.js"; +import { freezeStatusErrors, joinStatusErrors } from "./statusErrors.js"; import { isAbsoluteUrl } from "./url.js"; /** An endpoint's definition, as seen at runtime. */ export interface RuntimeDefinition { readonly output?: AnySchema | undefined; readonly error?: AnySchema | undefined; + readonly statusErrors?: StatusErrorMap | undefined; readonly input?: AnySchema | undefined; readonly headers?: Headers.Input | undefined; readonly plugins?: OverrideMap | undefined; } +/** What an endpoint takes from its client's config: the error defaults. */ +export interface ClientDefaults { + readonly defaultError?: AnySchema | undefined; + readonly statusErrors?: StatusErrorMap | undefined; +} + /** An endpoint, as seen at runtime. */ export interface RuntimeEndpoint { (options?: RuntimeOptions): ReturnType; @@ -26,6 +34,10 @@ export interface RuntimeEndpoint { readonly output: AnySchema | undefined; /** The `error` schema, else the client's `defaultError`, else `Schema.Unknown`. */ readonly error: AnySchema; + /** The endpoint's `statusErrors` joined with the client's, every entry a list. */ + readonly statusErrors: { + readonly [status: number]: ReadonlyArray; + }; /** The `input` schema, or `undefined` without one. */ readonly input: AnySchema | undefined; } @@ -40,6 +52,7 @@ export interface RuntimeUnboundEndpoint { readonly path: string; readonly output: AnySchema | undefined; readonly error: AnySchema | undefined; + readonly statusErrors: StatusErrorMap | undefined; readonly input: AnySchema | undefined; readonly headers: Headers.Headers | undefined; } @@ -89,12 +102,14 @@ export const parseRoute = ( * one call's options on top. The definition's schemas are fixed; its * headers and plugin overrides are defaults the call can override. * - * `defaultError` is the client's, used for the endpoint's `error` property - * when the definition has none. Calls fall back to it on their own. + * `client` holds the client's `defaultError`, used for the endpoint's + * `error` property when the definition has none, and its `statusErrors`, + * joined after the definition's in the `statusErrors` property. Calls fall + * back to both on their own. */ export const makeEndpoint = ( call: (method: HttpMethod.HttpMethod) => Call, - defaultError: AnySchema | undefined, + client: ClientDefaults, route: string, definition: RuntimeDefinition = {}, ): RuntimeEndpoint => { @@ -104,11 +119,13 @@ export const makeEndpoint = ( definition.headers === undefined ? undefined : Headers.fromInput(definition.headers); + const statusErrors = freezeStatusErrors(definition.statusErrors); const endpoint = (options: RuntimeOptions = {}) => send(path, { ...options, output: definition.output, error: definition.error, + statusErrors, input: definition.input, headers: mergeHeaders(headers, options.headers), plugins: mergeOverrides(definition.plugins, options.plugins), @@ -118,7 +135,8 @@ export const makeEndpoint = ( method, path, output: definition.output, - error: definition.error ?? defaultError ?? Schema.Unknown, + error: definition.error ?? client.defaultError ?? Schema.Unknown, + statusErrors: joinStatusErrors([statusErrors, client.statusErrors]), input: definition.input, }); }; @@ -138,6 +156,7 @@ export const makeUnboundEndpoint = ( path, output: definition.output, error: definition.error, + statusErrors: freezeStatusErrors(definition.statusErrors), input: definition.input, headers: definition.headers === undefined @@ -153,13 +172,14 @@ export const makeUnboundEndpoint = ( */ export const bindEndpoint = ( call: (method: HttpMethod.HttpMethod) => Call, - defaultError: AnySchema | undefined, + client: ClientDefaults, unbound: RuntimeUnboundEndpoint, options: RuntimeBindOptions = {}, ): RuntimeEndpoint => - makeEndpoint(call, defaultError, unbound.route, { + makeEndpoint(call, client, unbound.route, { output: unbound.output, error: unbound.error, + statusErrors: unbound.statusErrors, input: unbound.input, headers: mergeHeaders(unbound.headers, options.headers), plugins: options.plugins, diff --git a/src/internal/json.ts b/src/internal/json.ts new file mode 100644 index 0000000..d4919f2 --- /dev/null +++ b/src/internal/json.ts @@ -0,0 +1,7 @@ +import * as Schema from "effect/Schema"; + +import type { AnySchema } from "../Options.js"; + +/** Decodes a parsed JSON value with `schema`, the way every body is decoded. */ +export const decodeJson = (schema: S) => + Schema.decodeUnknownEffect(Schema.toCodecJson(schema)); diff --git a/src/internal/request.ts b/src/internal/request.ts index e17ae26..77ccb3f 100644 --- a/src/internal/request.ts +++ b/src/internal/request.ts @@ -6,7 +6,12 @@ import * as HttpClientRequest from "effect/http/HttpClientRequest"; import type * as HttpClientResponse from "effect/http/HttpClientResponse"; import type * as HttpMethod from "effect/http/HttpMethod"; -import type { AnySchema, RequestOptions } from "../Options.js"; +import type { + AnySchema, + QueryInput, + RequestOptions, + StatusErrorMap, +} from "../Options.js"; import type { Plugin } from "../Plugin.js"; import { encodeBody } from "./body.js"; import { interpolatePath } from "./path.js"; @@ -26,13 +31,17 @@ export type RuntimeOptions = Omit< AnySchema | undefined, AnySchema, AnySchema, - ReadonlyArray + ReadonlyArray, + QueryInput, + StatusErrorMap | undefined >, "plugins" > & { readonly plugins?: OverrideMap | undefined }; export interface RequesterState { readonly errorSchema: AnySchema | undefined; + /** The client's `statusErrors`, tried after the call's or endpoint's. */ + readonly statusErrors: StatusErrorMap | undefined; readonly applyDefaults: ApplyDefaults; readonly chain: HttpClient.HttpClient.With; readonly plugins: ReadonlyArray; @@ -86,6 +95,7 @@ const execute = ( const checked = yield* checkStatus( response, options.error ?? state.errorSchema, + [options.statusErrors, state.statusErrors], ); return options.output === undefined ? checked diff --git a/src/internal/response.ts b/src/internal/response.ts index 718193e..079a2f8 100644 --- a/src/internal/response.ts +++ b/src/internal/response.ts @@ -1,9 +1,11 @@ import * as Effect from "effect/Effect"; import type * as HttpClientResponse from "effect/http/HttpClientResponse"; -import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; import { DecodeError, HttpError } from "../Errors.js"; -import type { AnySchema } from "../Options.js"; +import type { AnySchema, StatusErrorMap } from "../Options.js"; +import { decodeJson } from "./json.js"; +import { decodeStatusError, statusErrorsFor } from "./statusErrors.js"; import { errorUrl } from "./url.js"; const messageOf = (error: unknown): string => @@ -17,32 +19,36 @@ const messageOf = (error: unknown): string => const parseBody = (text: string): unknown => text.trim() === "" ? undefined : JSON.parse(text); -const decodeJson = (schema: S) => - Schema.decodeUnknownEffect(Schema.toCodecJson(schema)); - -const readErrorBody = ( - text: string, - schema: EB | undefined, -): Effect.Effect => { - let json: unknown; +/** The parsed JSON of an error body, or `undefined` when it's empty or isn't JSON. */ +const parseErrorBody = (text: string): unknown => { try { - json = parseBody(text); + return parseBody(text); } catch { - return Effect.undefined; - } - if (json === undefined || schema === undefined) { - return Effect.succeed(json); + return undefined; } - return Effect.orElseSucceed(decodeJson(schema)(json), () => undefined); }; +const readErrorBody = ( + json: unknown, + schema: EB | undefined, +): Effect.Effect => + json === undefined || schema === undefined + ? Effect.succeed(json) + : Effect.orElseSucceed(decodeJson(schema)(json), () => undefined); + /** - * Passes a 2xx response through and fails anything else with an - * `HttpError`. 3xx counts as an error: `fetch` follows redirects itself, so - * a 3xx only arrives with `redirect: "manual"` or a conditional request - * (`304 Not Modified`). + * Passes a 2xx response through and fails anything else, with an error of + * `statusErrors` or an `HttpError`. 3xx counts as an error: `fetch` follows + * redirects itself, so a 3xx only arrives with `redirect: "manual"` or a + * conditional request (`304 Not Modified`). + * + * `statusErrors` are the maps to try, in order (the call's, the + * endpoint's, the client's). When the body is JSON, the schemas listed for + * the status are tried in turn, and the first one that decodes it is what + * the call fails with. * - * The error body is best effort and never fails the call: + * Otherwise it fails with an `HttpError`, whose body is best effort and + * never fails the call: * * - `text` is the raw body, or `""` if it couldn't be read. * - `body` is the JSON decoded with `errorSchema`, or the parsed JSON @@ -50,7 +56,8 @@ const readErrorBody = ( * doesn't match `errorSchema`. * * `body` is typed by `errorSchema` only when one is definitely given; a - * schema that may be `undefined` types it as `unknown`. + * schema that may be `undefined` types it as `unknown`. With + * `statusErrors`, the error is `unknown`. */ export function checkStatus( response: HttpClientResponse.HttpClientResponse, @@ -74,20 +81,31 @@ export function checkStatus( HttpError.With, EB["DecodingServices"] >; -export function checkStatus( +export function checkStatus( response: HttpClientResponse.HttpClientResponse, - errorSchema?: EB | undefined, -): Effect.Effect< - HttpClientResponse.HttpClientResponse, - HttpError, - EB["DecodingServices"] -> { + errorSchema: AnySchema | undefined, + statusErrors: ReadonlyArray, +): Effect.Effect; +export function checkStatus( + response: HttpClientResponse.HttpClientResponse, + errorSchema?: AnySchema | undefined, + statusErrors: ReadonlyArray = [], +): Effect.Effect { if (response.status >= 200 && response.status < 300) { return Effect.succeed(response); } return Effect.gen(function* () { const text = yield* Effect.orElseSucceed(response.text, () => ""); - const body = yield* readErrorBody(text, errorSchema); + const json = parseErrorBody(text); + if (json !== undefined) { + for (const schema of statusErrorsFor(response.status, statusErrors)) { + const error = yield* decodeStatusError(schema, json); + if (Option.isSome(error)) { + return yield* Effect.fail(error.value); + } + } + } + const body = yield* readErrorBody(json, errorSchema); return yield* new HttpError({ method: response.request.method, url: errorUrl(response.request.url), diff --git a/src/internal/statusErrors.ts b/src/internal/statusErrors.ts new file mode 100644 index 0000000..eca44c7 --- /dev/null +++ b/src/internal/statusErrors.ts @@ -0,0 +1,133 @@ +import * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SchemaAST from "effect/SchemaAST"; + +import type { AnySchema, StatusErrorMap } from "../Options.js"; +import { decodeJson } from "./json.js"; +import { hasOwn } from "./object.js"; + +/** A `StatusErrorMap` entry as a list. */ +const entryList = ( + entry: AnySchema | ReadonlyArray | undefined, +): ReadonlyArray => + entry === undefined + ? [] + : Array.isArray(entry) + ? entry + : [entry as AnySchema]; + +/** `true` for the keys of a map that are statuses, such as `"404"`. */ +const isStatusKey = (key: string): boolean => /^\d+$/.test(key); + +/** + * A frozen copy of `map`, with the same shape: a list entry is copied and + * frozen too. Maps are copied where they're given (`make`, an endpoint's + * definition), so changing the original later changes nothing. + */ +export const freezeStatusErrors = ( + map: M, +): M => + map === undefined + ? map + : (Object.freeze( + Object.fromEntries( + Object.entries(map).map(([status, entry]) => [ + status, + Array.isArray(entry) ? Object.freeze([...entry]) : entry, + ]), + ), + ) as M); + +/** + * The schemas to try for `status`, in order: each map's entry for it, the + * first map's first. A schema listed twice is tried once. + */ +export const statusErrorsFor = ( + status: number, + maps: ReadonlyArray, +): ReadonlyArray => { + const schemas = maps.flatMap((map) => + map === undefined ? [] : entryList(map[status]), + ); + return schemas.length < 2 ? schemas : [...new Set(schemas)]; +}; + +/** A joined map without any status. */ +const empty: { readonly [status: number]: ReadonlyArray } = + Object.freeze({}); + +/** + * The maps joined into one, every entry a list: for each status, the first + * map's schemas, then the next map's. Keys that aren't statuses are left + * out, as they never apply. Frozen, as it's only read. + */ +export const joinStatusErrors = ( + maps: ReadonlyArray, +): { readonly [status: number]: ReadonlyArray } => { + const statuses = new Set( + maps.flatMap((map) => + map === undefined ? [] : Object.keys(map).filter(isStatusKey), + ), + ); + if (statuses.size === 0) { + return empty; + } + const joined: Record> = {}; + for (const status of statuses) { + joined[status] = Object.freeze(statusErrorsFor(Number(status), maps)); + } + return Object.freeze(joined); +}; + +/** The `_tag` of each schema seen, or `null` for a schema without one. */ +const tags = new WeakMap(); + +/** + * The literal `_tag` the schema's JSON form has, such as `"InvalidRole"` + * for a `Schema.TaggedError` class, or `undefined` without one. It's read + * from the encoded side, so it also works through `Schema.encodeKeys` and + * `Schema.tagDefaultOmit`. + */ +export const tagOf = (schema: AnySchema): string | undefined => { + let tag = tags.get(schema); + if (tag === undefined) { + tag = null; + const encoded = SchemaAST.toEncoded(Schema.toCodecJson(schema).ast); + if (SchemaAST.isObjects(encoded)) { + const property = encoded.propertySignatures.find( + (signature) => signature.name === "_tag", + ); + if ( + property !== undefined && + SchemaAST.isLiteral(property.type) && + typeof property.type.literal === "string" + ) { + tag = property.type.literal; + } + } + tags.set(schema, tag); + } + return tag ?? undefined; +}; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * Decodes an error body with one `statusErrors` schema: some error when it + * matches, none when it doesn't. Backends don't send a `_tag`, so when the + * schema has a literal one and the body is an object without one, it's + * added first. A schema that dies while decoding is a defect. + */ +export const decodeStatusError = ( + schema: S, + json: unknown, +): Effect.Effect, never, S["DecodingServices"]> => { + const tag = tagOf(schema); + const body = + tag !== undefined && isRecord(json) && !hasOwn(json, "_tag") + ? { ...json, _tag: tag } + : json; + return Effect.option(decodeJson(schema)(body)); +}; diff --git a/test/statusErrors.test.ts b/test/statusErrors.test.ts new file mode 100644 index 0000000..1692d89 --- /dev/null +++ b/test/statusErrors.test.ts @@ -0,0 +1,562 @@ +import { it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; + +import { describe, expect } from "vitest"; + +import * as Fetch from "../src/index.js"; +import { retry } from "../src/plugins/index.js"; +import { setup } from "./utils/client.js"; +import { after } from "./utils/clock.js"; +import { reply } from "./utils/reply.js"; + +class TodoNotFound extends Schema.TaggedError()("TodoNotFound", { + message: Schema.String, +}) {} + +class InvalidRole extends Schema.TaggedError()("InvalidRole", { + code: Schema.Literal("INVALID_ROLE"), + message: Schema.String, +}) {} + +class RoleTaken extends Schema.TaggedError()("RoleTaken", { + code: Schema.Literal("ROLE_TAKEN"), + message: Schema.String, + takenBy: Schema.Finite, +}) {} + +class SessionExpired extends Schema.TaggedError()( + "SessionExpired", + { message: Schema.String }, +) {} + +/** Three errors that all match `{ message }`, to see which one wins. */ +class CallConflict extends Schema.TaggedError()("CallConflict", { + message: Schema.String, +}) {} +class EndpointConflict extends Schema.TaggedError()( + "EndpointConflict", + { message: Schema.String }, +) {} +class ClientConflict extends Schema.TaggedError()( + "ClientConflict", + { message: Schema.String }, +) {} + +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); +const ApiError = Schema.Struct({ code: Schema.String }); + +const roleErrors = { 400: [InvalidRole, RoleTaken] } as const; + +describe("a status with one schema", () => { + it.effect("fails the call with the decoded error", () => + Effect.gen(function* () { + const { api } = yield* setup([ + reply.json({ message: "No todo 1" }, { status: 404 }), + ]); + const error = yield* Effect.flip( + api.get("/todos/:id", { + params: { id: 1 }, + output: Todo, + statusErrors: { 404: TodoNotFound }, + }), + ); + expect(error).toBeInstanceOf(TodoNotFound); + expect(error).toMatchObject({ + _tag: "TodoNotFound", + message: "No todo 1", + }); + }), + ); + + it.effect("can be caught by its tag", () => + Effect.gen(function* () { + const { api } = yield* setup([ + reply.json({ message: "No todo 1" }, { status: 404 }), + ]); + const todo = yield* api + .get("/todos/:id", { + params: { id: 1 }, + output: Todo, + statusErrors: { 404: TodoNotFound }, + }) + .pipe( + Effect.catchTag("TodoNotFound", (error) => + Effect.succeed(error.message), + ), + ); + expect(todo).toBe("No todo 1"); + }), + ); +}); + +describe("a status with several schemas", () => { + it.effect.each([ + [ + { code: "INVALID_ROLE", message: "No such role" }, + InvalidRole, + { _tag: "InvalidRole", code: "INVALID_ROLE", message: "No such role" }, + ], + [ + { code: "ROLE_TAKEN", message: "Taken", takenBy: 7 }, + RoleTaken, + { + _tag: "RoleTaken", + code: "ROLE_TAKEN", + message: "Taken", + takenBy: 7, + }, + ], + ] as const)( + "picks the one whose literal matches: %j", + ([body, Class, expected]) => + Effect.gen(function* () { + const { api } = yield* setup([reply.json(body, { status: 400 })]); + const error = yield* Effect.flip( + api.post("/users/1/roles", { + body: { role: "owner" }, + statusErrors: roleErrors, + }), + ); + expect(error).toBeInstanceOf(Class); + expect(error).toMatchObject(expected); + }), + ); +}); + +describe("a response no schema decodes", () => { + it.effect("fails with an HttpError, its body decoded with error", () => + Effect.gen(function* () { + const { api } = yield* setup([ + reply.json({ code: "MISSING_FIELD" }, { status: 400 }), + ]); + const error = yield* Effect.flip( + api.post("/users/1/roles", { + body: {}, + error: ApiError, + statusErrors: roleErrors, + }), + ); + expect(error).toBeInstanceOf(Fetch.HttpError); + expect(error).toMatchObject({ + status: 400, + body: { code: "MISSING_FIELD" }, + }); + }), + ); + + it.effect.each([ + ["an empty body", reply.empty(400), ""], + [ + "a body that isn't JSON", + reply.text("Bad Request", { status: 400 }), + "Bad Request", + ], + [ + "a JSON array", + reply.json([{ code: "INVALID_ROLE" }], { status: 400 }), + '[{"code":"INVALID_ROLE"}]', + ], + ] as const)("fails with an HttpError for %s", ([, next, text]) => + Effect.gen(function* () { + const { api } = yield* setup([next]); + const error = yield* Effect.flip( + api.post("/users/1/roles", { body: {}, statusErrors: roleErrors }), + ); + expect(error).toBeInstanceOf(Fetch.HttpError); + expect(error).toMatchObject({ status: 400, text }); + }), + ); + + it.effect("fails with an HttpError for a status that isn't listed", () => + Effect.gen(function* () { + const { api } = yield* setup([ + reply.json({ code: "INVALID_ROLE", message: "x" }, { status: 500 }), + ]); + const error = yield* Effect.flip( + api.post("/users/1/roles", { body: {}, statusErrors: roleErrors }), + ); + expect(error).toBeInstanceOf(Fetch.HttpError); + }), + ); + + it.effect("never applies to a 2xx", () => + Effect.gen(function* () { + const { api } = yield* setup([ + reply.json({ id: 1, title: "Write docs" }), + ]); + const todo = yield* api.get("/todos/1", { + output: Todo, + statusErrors: { 200: TodoNotFound }, + }); + expect(todo).toEqual({ id: 1, title: "Write docs" }); + }), + ); +}); + +describe("the _tag", () => { + it.effect("is added when the schema has a literal one", () => + Effect.gen(function* () { + const { api } = yield* setup([ + reply.json({ message: "gone" }, { status: 404 }), + ]); + const error = yield* Effect.flip( + api.get("/x", { statusErrors: { 404: TodoNotFound } }), + ); + expect(error).toMatchObject({ _tag: "TodoNotFound" }); + }), + ); + + it.effect("is left alone when the body has its own", () => + Effect.gen(function* () { + const { api } = yield* setup([ + reply.json({ _tag: "Other", message: "gone" }, { status: 404 }), + ]); + const error = yield* Effect.flip( + api.get("/x", { statusErrors: { 404: TodoNotFound } }), + ); + expect(error).toBeInstanceOf(Fetch.HttpError); + }), + ); + + it.effect("isn't added to a body that isn't an object", () => + Effect.gen(function* () { + const { api } = yield* setup([reply.json("gone", { status: 404 })]); + const error = yield* Effect.flip( + api.get("/x", { statusErrors: { 404: TodoNotFound } }), + ); + expect(error).toBeInstanceOf(Fetch.HttpError); + expect(error).toMatchObject({ body: "gone" }); + }), + ); + + it.effect( + "isn't added for a Schema.Union: its members are listed instead", + () => + Effect.gen(function* () { + const { api } = yield* setup([ + reply.json( + { code: "ROLE_TAKEN", message: "x", takenBy: 1 }, + { status: 400 }, + ), + ]); + const asUnion = yield* Effect.flip( + api.get("/x", { + statusErrors: { 400: Schema.Union([InvalidRole, RoleTaken]) }, + }), + ); + expect(asUnion).toBeInstanceOf(Fetch.HttpError); + const asList = yield* Effect.flip( + api.get("/x", { + statusErrors: { 400: [InvalidRole, RoleTaken] }, + }), + ); + expect(asList).toBeInstanceOf(RoleTaken); + }), + ); + + it.effect("isn't needed by a schema without one", () => + Effect.gen(function* () { + const Problem = Schema.Struct({ title: Schema.String }); + const { api } = yield* setup([ + reply.json({ title: "Conflict" }, { status: 409 }), + ]); + const error = yield* Effect.flip( + api.get("/x", { statusErrors: { 409: Problem } }), + ); + expect(error).toEqual({ title: "Conflict" }); + }), + ); + + it.effect("is added through Schema.encodeKeys", () => + Effect.gen(function* () { + const Renamed = InvalidRole.pipe( + Schema.encodeKeys({ code: "error_code" }), + ); + const { api } = yield* setup([ + reply.json( + { error_code: "INVALID_ROLE", message: "No such role" }, + { status: 400 }, + ), + ]); + const error = yield* Effect.flip( + api.get("/x", { statusErrors: { 400: Renamed } }), + ); + expect(error).toBeInstanceOf(InvalidRole); + expect(error).toMatchObject({ code: "INVALID_ROLE" }); + }), + ); +}); + +describe("the order of the maps", () => { + const conflict = () => [reply.json({ message: "x" }, { status: 409 })]; + + it.effect("tries the call's before the client's", () => + Effect.gen(function* () { + const { api } = yield* setup(conflict(), { + statusErrors: { 409: ClientConflict }, + }); + const error = yield* Effect.flip( + api.get("/x", { statusErrors: { 409: CallConflict } }), + ); + expect(error).toBeInstanceOf(CallConflict); + }), + ); + + it.effect("tries the endpoint's before the client's", () => + Effect.gen(function* () { + const { api } = yield* setup(conflict(), { + statusErrors: { 409: ClientConflict }, + }); + const endpoint = api.endpoint("GET /x", { + statusErrors: { 409: EndpointConflict }, + }); + const error = yield* Effect.flip(endpoint()); + expect(error).toBeInstanceOf(EndpointConflict); + }), + ); + + it.effect("tries the client's when the endpoint's don't match", () => + Effect.gen(function* () { + const { api } = yield* setup(conflict(), { + statusErrors: { 409: ClientConflict }, + }); + const endpoint = api.endpoint("GET /x", { + statusErrors: { 409: RoleTaken }, + }); + const error = yield* Effect.flip(endpoint()); + expect(error).toBeInstanceOf(ClientConflict); + }), + ); + + it.effect("uses the client's for a status the endpoint doesn't list", () => + Effect.gen(function* () { + const { api } = yield* setup( + [reply.json({ message: "expired" }, { status: 401 })], + { statusErrors: { 401: SessionExpired } }, + ); + const getTodo = api.endpoint("GET /todos/:id", { + output: Todo, + statusErrors: { 404: TodoNotFound }, + }); + const error = yield* Effect.flip(getTodo({ params: { id: 1 } })); + expect(error).toBeInstanceOf(SessionExpired); + }), + ); + + it.effect("binds a definition's before the client's", () => + Effect.gen(function* () { + const { api } = yield* setup( + [ + reply.json({ message: "x" }, { status: 409 }), + reply.json({ message: "expired" }, { status: 401 }), + ], + { statusErrors: { 401: SessionExpired, 409: ClientConflict } }, + ); + const getX = api.bind( + Fetch.endpoint("GET /x", { + statusErrors: { 409: EndpointConflict }, + }), + ); + expect(yield* Effect.flip(getX())).toBeInstanceOf(EndpointConflict); + expect(yield* Effect.flip(getX())).toBeInstanceOf(SessionExpired); + }), + ); +}); + +describe("endpoints", () => { + it.effect("expose their map joined with the client's", () => + Effect.gen(function* () { + const { api } = yield* setup([], { + statusErrors: { 401: SessionExpired, 409: ClientConflict }, + }); + const endpoint = api.endpoint("GET /x", { + statusErrors: { + 409: [EndpointConflict, ClientConflict], + 404: TodoNotFound, + }, + }); + expect(endpoint.statusErrors).toEqual({ + 401: [SessionExpired], + 404: [TodoNotFound], + 409: [EndpointConflict, ClientConflict], + }); + expect(Object.isFrozen(endpoint.statusErrors)).toBe(true); + expect(api.endpoint("GET /y").statusErrors).toEqual({ + 401: [SessionExpired], + 409: [ClientConflict], + }); + }), + ); + + it.effect("are made by a client that exposes its map", () => + Effect.gen(function* () { + const { api } = yield* setup([], { + statusErrors: { 401: SessionExpired }, + }); + expect(api.statusErrors).toEqual({ 401: SessionExpired }); + expect(Object.isFrozen(api.statusErrors)).toBe(true); + const { api: plain } = yield* setup([]); + expect(plain.statusErrors).toBeUndefined(); + }), + ); + + it.effect("have an empty map when neither has one", () => + Effect.gen(function* () { + const { api } = yield* setup([]); + expect(api.endpoint("GET /x").statusErrors).toEqual({}); + }), + ); + + it("defined without a client expose a frozen copy of their map", () => { + const statusErrors = { 404: TodoNotFound, 400: [InvalidRole, RoleTaken] }; + const definition = Fetch.endpoint("GET /x", { statusErrors }); + expect(definition.statusErrors).toEqual(statusErrors); + expect(definition.statusErrors).not.toBe(statusErrors); + expect(Object.isFrozen(definition.statusErrors)).toBe(true); + expect(Object.isFrozen(definition.statusErrors?.[400])).toBe(true); + expect(Fetch.endpoint("GET /x").statusErrors).toBeUndefined(); + }); + + it.effect("leave out keys that aren't statuses", () => + Effect.gen(function* () { + const { api } = yield* setup([]); + const endpoint = api.endpoint("GET /x", { + statusErrors: { 404: TodoNotFound, notFound: TodoNotFound }, + }); + expect(endpoint.statusErrors).toEqual({ 404: [TodoNotFound] }); + }), + ); + + it.effect( + "keep the maps they were given, whatever happens to them later", + () => + Effect.gen(function* () { + const clientMap: Record = {}; + const endpointMap: Record = {}; + const { api } = yield* setup( + [reply.json({ message: "x" }, { status: 409 })], + { statusErrors: clientMap }, + ); + const endpoint = api.endpoint("GET /x", { + statusErrors: endpointMap, + }); + const definition = Fetch.endpoint("GET /x", { + statusErrors: endpointMap, + }); + clientMap[409] = ClientConflict; + endpointMap[409] = EndpointConflict; + expect(api.statusErrors).toEqual({}); + expect(endpoint.statusErrors).toEqual({}); + expect(definition.statusErrors).toEqual({}); + expect(yield* Effect.flip(endpoint())).toBeInstanceOf( + Fetch.HttpError, + ); + }), + ); +}); + +describe("decoding", () => { + class Locale extends Context.Service()( + "Locale", + ) {} + class Localized extends Schema.TaggedError()("Localized", { + message: Schema.String.pipe( + Schema.decode({ + decode: SchemaGetter.transformEffect((s: string) => + Effect.map(Effect.service(Locale), (l) => `${l.tag}:${s}`), + ), + encode: SchemaGetter.passthrough(), + }), + ), + }) {} + + it.effect("runs with the services the schema needs", () => + Effect.gen(function* () { + const { api } = yield* setup([ + reply.json({ message: "gone" }, { status: 404 }), + ]); + const error = yield* Effect.flip( + api.get("/x", { statusErrors: { 404: Localized } }), + ).pipe(Effect.provideService(Locale, { tag: "fr" })); + expect(error).toMatchObject({ _tag: "Localized", message: "fr:gone" }); + }), + ); + + it.effect("dies when the schema dies", () => + Effect.gen(function* () { + const Broken = Schema.String.pipe( + Schema.decode({ + decode: SchemaGetter.transform(() => { + throw new Error("broken"); + }), + encode: SchemaGetter.passthrough(), + }), + ); + const { api } = yield* setup([reply.json("x", { status: 404 })]); + const exit = yield* Effect.exit( + api.get("/x", { statusErrors: { 404: Broken } }), + ); + expect(Exit.hasDies(exit)).toBe(true); + }), + ); +}); + +describe("plugins", () => { + it.effect( + "see the raw response in onResponse, then the error in onError", + () => + Effect.gen(function* () { + const seen: Array = []; + const observer = Fetch.plugin({ + name: "observer", + onResponse: (response) => + Effect.sync(() => { + seen.push(response.status); + return response; + }), + onError: (error) => Effect.sync(() => seen.push(error)), + }); + const { api } = yield* setup( + [reply.json({ message: "gone" }, { status: 404 })], + { plugins: [observer] }, + ); + const error = yield* Effect.flip( + api.get("/x", { statusErrors: { 404: TodoNotFound } }), + ); + expect(error).toBeInstanceOf(TodoNotFound); + expect(seen).toEqual([404, error]); + }), + ); + + it.effect("retry still retries by status", () => + Effect.gen(function* () { + const Unavailable = Schema.Struct({ message: Schema.String }); + const { mock, api } = yield* setup( + [reply.json({ message: "busy" }, { status: 503 })], + { + plugins: [retry({ schedule: Schedule.spaced("1 second") })], + statusErrors: { 503: Unavailable }, + }, + ); + const error = yield* after(Effect.flip(api.get("/x")), "1 minute"); + expect(error).toEqual({ message: "busy" }); + expect(mock.requests).toHaveLength(3); + }), + ); + + it.effect("leave httpClient raw", () => + Effect.gen(function* () { + const { api } = yield* setup( + [reply.json({ message: "gone" }, { status: 404 })], + { statusErrors: { 404: TodoNotFound } }, + ); + const response = yield* api.httpClient.get("/x"); + expect(response.status).toBe(404); + }), + ); +}); diff --git a/test/types/statusErrors.test-d.ts b/test/types/statusErrors.test-d.ts new file mode 100644 index 0000000..1463c34 --- /dev/null +++ b/test/types/statusErrors.test-d.ts @@ -0,0 +1,444 @@ +// Type tests build Effects only to check their types; none are run. +// @effect-diagnostics floatingEffect:off + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; + +import { describe, expectTypeOf, it } from "vitest"; + +import { type Client, make } from "../../src/Client.js"; +import { endpoint as define } from "../../src/Endpoint.js"; +import type { + BindMethod, + Endpoint, + EndpointMethod, + UnboundEndpoint, +} from "../../src/Endpoint.js"; +import type { DecodeError, HttpError, NetworkError } from "../../src/Errors.js"; +import type { + AnySchema, + BodyMethod, + CallEffect, + CallError, + CallServices, + NoBodyMethod, + RequestMethod, + StatusError, + StatusErrorMap, + StatusErrorSchema, + StatusErrorServices, + Success, +} from "../../src/Options.js"; + +class TodoNotFound extends Schema.TaggedError()("TodoNotFound", { + message: Schema.String, +}) {} +class InvalidRole extends Schema.TaggedError()("InvalidRole", { + code: Schema.Literal("INVALID_ROLE"), + message: Schema.String, +}) {} +class RoleTaken extends Schema.TaggedError()("RoleTaken", { + code: Schema.Literal("ROLE_TAKEN"), + takenBy: Schema.Finite, +}) {} +class SessionExpired extends Schema.TaggedError()( + "SessionExpired", + { message: Schema.String }, +) {} + +class Locale extends Context.Service()( + "Locale", +) {} +class Localized extends Schema.TaggedError()("Localized", { + message: Schema.String.pipe( + Schema.decode({ + decode: SchemaGetter.transformEffect((s: string) => + Effect.map(Effect.service(Locale), (l) => `${l.tag}:${s}`), + ), + encode: SchemaGetter.passthrough(), + }), + ), +}) {} + +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); +const ApiError = Schema.Struct({ code: Schema.String }); + +type Todo = typeof Todo.Type; +type ApiError = typeof ApiError.Type; +type Ps = []; + +/** effetch's own errors for a call with an `output`. */ +type Own = NetworkError | DecodeError | HttpError.With; + +type Session = { readonly 401: typeof SessionExpired }; +declare const get: NoBodyMethod; +declare const post: BodyMethod; +declare const request: RequestMethod; +declare const endpoint: EndpointMethod; +declare const bind: BindMethod; +declare const bare: NoBodyMethod; + +describe("StatusErrorMap", () => { + it("takes a schema or a list of schemas per status", () => { + expectTypeOf({ + 404: TodoNotFound, + 400: [InvalidRole, RoleTaken], + }).toExtend(); + // @ts-expect-error not a schema + bare("/x", { statusErrors: { 404: "TodoNotFound" } }); + }); +}); + +describe("StatusError", () => { + it("is the union of the schemas' types", () => { + expectTypeOf< + StatusError<{ 404: typeof TodoNotFound; 400: [typeof InvalidRole] }> + >().toEqualTypeOf(); + }); + + it("takes a union of maps", () => { + expectTypeOf< + StatusError<{ 404: typeof TodoNotFound } | Session | undefined> + >().toEqualTypeOf(); + }); + + it("is never without a map", () => { + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + }); + + it("has services of its own", () => { + expectTypeOf< + StatusErrorServices<{ 404: typeof Localized } | Session> + >().toEqualTypeOf(); + }); +}); + +describe("a call", () => { + it("fails with its own, then the client's errors", () => { + const call = get("/todos/:id", { + params: { id: 1 }, + output: Todo, + statusErrors: { 404: TodoNotFound }, + }); + expectTypeOf(call).toEqualTypeOf< + Effect.Effect + >(); + }); + + it("keeps the client's errors without its own", () => { + const call = get("/x", { output: Todo }); + expectTypeOf>().toEqualTypeOf< + Own | SessionExpired + >(); + }); + + it("is unchanged without any map", () => { + const call = bare("/x", { output: Todo }); + expectTypeOf(call).toEqualTypeOf>(); + const explicit = bare("/x", { output: Todo, statusErrors: undefined }); + expectTypeOf(explicit).toEqualTypeOf>(); + }); + + it("works on every method", () => { + const posted = post("/x", { + body: {}, + statusErrors: { 400: [InvalidRole, RoleTaken] }, + }); + expectTypeOf>().toEqualTypeOf< + | NetworkError + | HttpError.With + | InvalidRole + | RoleTaken + | SessionExpired + >(); + const requested = request("PATCH", "/x", { + statusErrors: { 404: TodoNotFound }, + }); + expectTypeOf>().toEqualTypeOf< + NetworkError | HttpError.With | TodoNotFound | SessionExpired + >(); + }); + + it("narrows literal fields by tag", () => { + const call = post("/x", { + body: {}, + statusErrors: { 400: [InvalidRole, RoleTaken] }, + }).pipe( + Effect.catchTag("InvalidRole", (error) => { + expectTypeOf(error.code).toEqualTypeOf<"INVALID_ROLE">(); + return Effect.succeed(error.message); + }), + ); + expectTypeOf>().toEqualTypeOf< + NetworkError | HttpError.With | RoleTaken | SessionExpired + >(); + }); + + it("requires the services of its errors' schemas", () => { + const call = get("/x", { statusErrors: { 404: Localized } }); + expectTypeOf>().toEqualTypeOf(); + }); + + it("matches CallEffect, CallError and CallServices exactly", () => { + type Map = { readonly 404: typeof Localized }; + const call = get("/x", { + output: Todo, + statusErrors: { 404: Localized } as Map, + }); + expectTypeOf(call).toEqualTypeOf< + Effect.Effect< + Todo, + CallError, + CallServices + > + >(); + expectTypeOf(call).toEqualTypeOf< + CallEffect< + typeof Todo, + typeof ApiError, + Schema.Unknown, + Ps, + Map | Session + > + >(); + }); + + it("matches the aliases on post and request too", () => { + type Map = { + readonly 400: readonly [typeof Localized, typeof RoleTaken]; + }; + const statusErrors: Map = { 400: [Localized, RoleTaken] }; + const posted = post("/x", { body: {}, output: Todo, statusErrors }); + expectTypeOf(posted).toEqualTypeOf< + CallEffect< + typeof Todo, + typeof ApiError, + Schema.Unknown, + Ps, + Map | Session + > + >(); + const requested = request("PATCH", "/x", { output: Todo, statusErrors }); + expectTypeOf(requested).toEqualTypeOf< + CallEffect< + typeof Todo, + typeof ApiError, + Schema.Unknown, + Ps, + Map | Session + > + >(); + expectTypeOf>().toEqualTypeOf(); + }); + + it("ignores keys that aren't statuses", () => { + const call = bare("/x", { statusErrors: { notFound: TodoNotFound } }); + expectTypeOf>().toEqualTypeOf< + NetworkError | HttpError.With + >(); + expectTypeOf< + StatusError<{ "404": typeof TodoNotFound }> + >().toEqualTypeOf(); + }); + + it("fits CallEffect in generic code", () => { + const getAs = < + O extends AnySchema, + SE extends StatusErrorMap | undefined, + >( + method: NoBodyMethod, + output: O, + ): CallEffect => + method("/x", { output }); + expectTypeOf(getAs).toBeFunction(); + }); +}); + +describe("an endpoint", () => { + it("fails with its own, then the client's errors", () => { + const getTodo = endpoint("GET /todos/:id", { + output: Todo, + statusErrors: { 404: TodoNotFound }, + }); + expectTypeOf(getTodo).toEqualTypeOf< + Endpoint< + "GET /todos/:id", + typeof Todo, + typeof ApiError, + undefined, + Ps, + { 404: typeof TodoNotFound } | Session + > + >(); + const call = getTodo({ params: { id: 1 } }); + expectTypeOf(call).toEqualTypeOf< + Effect.Effect + >(); + }); + + it("is unchanged without any map", () => { + const getTodo = (api: Client) => + api.endpoint("GET /todos/:id", { output: Todo }); + expectTypeOf>().toEqualTypeOf< + Endpoint<"GET /todos/:id", typeof Todo, typeof ApiError, undefined, Ps> + >(); + }); + + it("exposes the joined map as lists of its schemas", () => { + const getTodo = endpoint("GET /todos/:id", { + statusErrors: { 404: TodoNotFound }, + }); + expectTypeOf(getTodo.statusErrors).toEqualTypeOf<{ + readonly [status: number]: ReadonlyArray< + typeof TodoNotFound | typeof SessionExpired + >; + }>(); + }); + + it("rejects a per-call map", () => { + const getTodo = endpoint("GET /todos/:id", { output: Todo }); + // @ts-expect-error the schemas are fixed by the endpoint + getTodo({ params: { id: 1 }, statusErrors: { 404: TodoNotFound } }); + }); + + it("can't be used as an endpoint without its errors", () => { + const getTodo = endpoint("GET /todos/:id", { + statusErrors: { 404: TodoNotFound }, + }); + expectTypeOf(getTodo).not.toExtend< + Endpoint<"GET /todos/:id", undefined, typeof ApiError, undefined, Ps> + >(); + }); + + it("matches CallEffect, CallError and CallServices exactly", () => { + type Map = { readonly 404: typeof Localized }; + const call = endpoint("GET /x", { + output: Todo, + statusErrors: { 404: Localized } as Map, + })(); + expectTypeOf(call).toEqualTypeOf< + Effect.Effect< + Todo, + CallError, + CallServices + > + >(); + expectTypeOf(call).toEqualTypeOf< + CallEffect + >(); + }); +}); + +describe("a definition", () => { + const getTodo = define("GET /todos/:id", { + output: Todo, + statusErrors: { 404: TodoNotFound }, + }); + + it("keeps its map's type", () => { + expectTypeOf(getTodo).toEqualTypeOf< + UnboundEndpoint< + "GET /todos/:id", + typeof Todo, + undefined, + undefined, + { 404: typeof TodoNotFound } + > + >(); + expectTypeOf(getTodo.statusErrors).toEqualTypeOf<{ + 404: typeof TodoNotFound; + }>(); + expectTypeOf(define("GET /x").statusErrors).toEqualTypeOf(); + }); + + it("fails, once bound, with its own, then the client's errors", () => { + const call = bind(getTodo)({ params: { id: 1 } }); + expectTypeOf(call).toEqualTypeOf< + Effect.Effect + >(); + expectTypeOf(call).toEqualTypeOf< + CallEffect< + typeof Todo, + typeof ApiError, + undefined, + Ps, + { 404: typeof TodoNotFound } | Session + > + >(); + const plain = bind(define("GET /todos/:id", { output: Todo }))({ + params: { id: 1 }, + }); + expectTypeOf(plain).toEqualTypeOf< + Effect.Effect + >(); + }); +}); + +describe("StatusErrorSchema", () => { + it("is the union of the schemas, under status keys only", () => { + expectTypeOf< + StatusErrorSchema<{ + 404: typeof TodoNotFound; + 400: [typeof InvalidRole, typeof RoleTaken]; + other: typeof SessionExpired; + }> + >().toEqualTypeOf< + typeof TodoNotFound | typeof InvalidRole | typeof RoleTaken + >(); + expectTypeOf>().toBeNever(); + }); +}); + +describe("make", () => { + it("infers the client's map", () => { + const client = make({ + defaultError: ApiError, + statusErrors: { 401: SessionExpired }, + }); + expectTypeOf>().toEqualTypeOf< + Client<[], typeof ApiError, { 401: typeof SessionExpired }> + >(); + }); + + it("is unchanged without one", () => { + const client = make({ defaultError: ApiError }); + expectTypeOf>().toEqualTypeOf< + Client<[], typeof ApiError> + >(); + const explicit = make({ + defaultError: ApiError, + statusErrors: undefined, + }); + expectTypeOf>().toEqualTypeOf< + Client<[], typeof ApiError> + >(); + }); + + it("exposes the client's map", () => { + const statusErrors = (api: Client) => + api.statusErrors; + expectTypeOf>().toEqualTypeOf(); + const none = (api: Client) => api.statusErrors; + expectTypeOf>().toEqualTypeOf(); + }); + + it("can't be used as a client without its errors", () => { + expectTypeOf>().not.toExtend< + Client + >(); + }); + + it("gives the client's errors to every call", () => { + const call = (api: Client) => + api.get("/x", { output: Todo }); + expectTypeOf>>().toEqualTypeOf< + Own | SessionExpired + >(); + const success: Success = { id: 1, title: "x" }; + expectTypeOf(success).toEqualTypeOf(); + }); +}); diff --git a/test/utils/client.ts b/test/utils/client.ts index dba05f5..153b429 100644 --- a/test/utils/client.ts +++ b/test/utils/client.ts @@ -17,9 +17,10 @@ export const setup = < EB extends Fetch.AnySchema = Schema.Unknown, E = never, R = never, + SE extends Fetch.StatusErrorMap | undefined = undefined, >( script: Script, - config: Fetch.ClientConfig = {}, + config: Fetch.ClientConfig = {}, ) => Effect.gen(function* () { const mock = makeMockClient(script); From 53ba689351400f003b951238b1fbd1f2c1fa7a14 Mon Sep 17 00:00:00 2001 From: monarcode Date: Sun, 8 Nov 2026 23:13:33 +0100 Subject: [PATCH 2/4] feat(plugins): add onExit hook and consoleLogger plugin - Add an `onExit(exit, ctx)` plugin hook that runs once per call with its `Exit`, response and duration, and a `RExit` type parameter for its services - Add `consoleLogger` to `@monarcode/effetch/plugins`: per-call console groups with headers, bodies and errors, redaction, colors, filters and per-call options - Document both in the README and CHANGELOG, update `examples/builtins.ts`, and add tests and smoke checks --- CHANGELOG.md | 22 + README.md | 166 +++- examples/README.md | 2 +- examples/builtins.ts | 24 +- scripts/smoke.sh | 17 +- src/Client.ts | 7 +- src/Plugin.ts | 113 ++- src/index.ts | 2 +- src/internal/plugins.ts | 143 ++- src/internal/request.ts | 94 +- src/internal/statusErrors.ts | 6 +- src/plugins/consoleLogger.ts | 1294 ++++++++++++++++++++++++++++ src/plugins/index.ts | 10 + test/exports.test.ts | 1 + test/plugins.test.ts | 380 +++++++- test/plugins/consoleLogger.test.ts | 950 ++++++++++++++++++++ test/types/builtins.test-d.ts | 33 +- test/types/plugin.test-d.ts | 57 +- 18 files changed, 3264 insertions(+), 57 deletions(-) create mode 100644 src/plugins/consoleLogger.ts create mode 100644 test/plugins/consoleLogger.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b74851..12fc726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,28 @@ All notable changes to `@monarcode/effetch`, newest first. type references keep compiling. Values typed as `Client`, `Endpoint` or `UnboundEndpoint` that you build by hand, such as test doubles, need a `statusErrors` property. +- Plugins can have an `onExit(exit, ctx)` hook. It runs once per call, when + it's over, whatever the outcome: success, failure, defect or interruption. + It gets the call's `Exit` (with the decoded `output`, or the response for + a call without one) and a `PluginExitContext`: the `PluginContext` fields, + the `response` (the one the plugins returned, else the last one the + network sent, or `undefined`), and `durationMs`, + from sending the request to decoding the body, retries included. Hooks + run in array order, after the `onError` hooks, and can't change the + outcome; one that dies is logged at Debug level. They don't run for + `client.httpClient`, or for a plugin the call skips. + - `Plugin.Definition` gains an `RExit` type parameter for the hook's + services, last and defaulting to `never`, so existing references keep + compiling. +- `consoleLogger` (from `@monarcode/effetch/plugins`) prints each call to the + console while you develop: a line with the route, status, duration, size + and outcome, opening a group with the full URL, the request and response + headers and bodies, and the error. It redacts sensitive headers, query + parameters and body keys (in errors too), reads only text bodies of at + most 1 MB and never the body of a raw or interrupted call, colors with CSS + or ANSI codes, and can be limited to errors, filtered, or set per call. Its `enabled` option turns it off in + production while keeping the client's type. Groups start collapsed, + except in React Native, whose Metro terminal drops collapsed groups. ## 0.1.0-rc.1 diff --git a/README.md b/README.md index 98d6e79..cb71c5c 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ const todo = api.get("/todos/:id", { params: { id: 1 }, output: Todo }); - [Bugs are defects](#bugs-are-defects) - [Plugins](#plugins) - [Built-in plugins](#built-in-plugins) + - [Console logging](#console-logging) - [Plugin order](#plugin-order) - [Changing plugins for one call](#changing-plugins-for-one-call) - [Writing plugins](#writing-plugins) @@ -730,6 +731,7 @@ export const withEffectToken = bearer({ token: Effect.succeed("token") }); | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | | `bearer({ token, scheme? })` | Sends `Authorization: Bearer `. The token is a string, a function of the request (sync or async), or an Effect, read for every request. `null`, `undefined` and `""` send no header, and an existing `Authorization` header is kept. | `false` | | `logger({ level? })` | Logs one entry per call at Debug (or Info): `GET 200 (35ms)`, or `failed` with the cause, with the call's [route](#knowing-the-route) as an annotation. Never logs headers or query strings. | `false` | +| `consoleLogger(options?)` | Prints each call to the console while you develop: a line with the route, status, duration and outcome, opening a group with the full URL, headers, bodies and error. Sensitive headers, query parameters and body keys are redacted. See [Console logging](#console-logging). | `false`, its settings | | `retry({ times?, schedule?, methods?, statuses? })` | Retries network errors and 408, 429, 500, 502, 503 and 504, 2 times by default, with jittered exponential backoff from 200ms. Only idempotent methods (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`) by default, so a `POST` is never sent twice. When retries run out, the last response is returned. | `false`, `{ times }` | | `timeout({ duration })` | Fails with `TimeoutError` and aborts the request when the response headers don't arrive in time. Downloading the body isn't covered. | `false`, `{ duration }` | @@ -737,12 +739,136 @@ A `bearer` token function that throws, or whose promise rejects, makes the call die. To fail with a typed error instead, pass an Effect: its error and services become the client's, and `make` asks for the services. +### Console logging + +`logger` writes one structured entry per call for production logs, and leaves +headers, query strings and bodies out. `consoleLogger` is for development: it +prints everything about a call to the console, as a group devtools can +expand. + +```ts +import * as Fetch from "@monarcode/effetch"; +import { consoleLogger } from "@monarcode/effetch/plugins"; + +declare const isDevelopment: boolean; + +export const makeApi = Fetch.make({ + baseUrl: "https://api.example.com", + plugins: [ + consoleLogger({ + enabled: isDevelopment, + slow: "1 second", + redact: { body: ["otp"] }, + }), + ], +}); +``` + +Each call prints a line, which opens a group with the details: + +```text +▸ GET /todos/:id 200 OK 35 ms 1.2 kB +▸ ✗ GET /todos 3.0 s → NetworkError: Failed to fetch +▸ ⚠ GET /reports/:id 200 OK 2.4 s (slow) +▾ ✗ POST /users/:id/roles 400 Bad Request 120 ms → InvalidRole: No such role + POST https://api.example.com/users/7/roles + request headers { accept: "application/json", authorization: "" } + request body { role: "owner" } + response headers { "content-type": "application/json" } + response body { code: "INVALID_ROLE", message: "No such role" } + error InvalidRole { code: "INVALID_ROLE", message: "No such role" } +``` + +It prints each call once it's over, with the error it ended with, after +[`statusErrors`](#errors-by-status) and decoding. Values are printed as +objects, so devtools can expand them. + +| Option | Default | What it does | +| --------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `true` | `false` prints nothing, and keeps the plugin's type, so it can stay in the list in production. | +| `detail` | `"full"` | `"summary"` prints the line alone; `"headers"` adds the URL, headers and error; `"full"` adds the bodies. | +| `only` | `"all"` | `"errors"` prints failed calls only. | +| `collapsed` | `true` | Whether groups start collapsed. `false` by default in React Native, where Metro's terminal drops collapsed groups. Node, Bun and Deno print them expanded. | +| `slow` | `false` | A duration, such as `"1 second"`: slower calls are flagged. | +| `maxBodyLength` | `10_000` | Body text longer than this is cut. JSON within it is printed as an object. | +| `redact` | | `{ headers, query, body }`: more names to redact, as strings (ignoring case, `-` and `_`) or regular expressions. `false` redacts nothing. | +| `filter` | | `(call) => boolean`: prints only the calls it accepts. `call` has the `route`, the `request`, the `response`, the `exit` and the `durationMs`. | +| `color` | `"auto"` | `"css"` (browser devtools), `"ansi"` (terminals), or `false`. `"auto"` uses `"css"` where there's a `document`, else plain text. | + +Calls can change `detail`, `only`, `collapsed`, `slow` and `maxBodyLength`, or +skip it with `false`: + +```ts +import * as Effect from "effect/Effect"; + +import * as Fetch from "@monarcode/effetch"; +import { consoleLogger } from "@monarcode/effetch/plugins"; + +export const program = Effect.gen(function* () { + const api = yield* Fetch.make({ + baseUrl: "https://api.example.com", + plugins: [consoleLogger()], + }); + yield* api.get("/reports/export", { + plugins: { consoleLogger: { detail: "summary" } }, + }); + yield* api.get("/health", { plugins: { consoleLogger: false } }); +}); +``` + +What it redacts, as ``, matching names ignoring case, `-` and `_`: + +- headers in Effect's `Headers.CurrentRedactedNames` (`authorization`, + `cookie`, `set-cookie`, `x-api-key`), `proxy-authorization`, + `x-auth-token`, `x-access-token`, `x-refresh-token`, `x-session-token`, + `x-csrf-token`, `x-xsrf-token`, `x-client-secret` and + `x-amz-security-token`; +- keys in JSON and form bodies, at any depth: `token`, `accessToken`, + `refreshToken`, `idToken`, `authToken`, `apiToken`, `sessionToken`, + `csrfToken`, `xsrfToken`, `secret`, `clientSecret`, `apiSecret`, + `apiKey`, `privateKey`, and any name containing `password`; +- query parameters with the same names, `key`, `sig`, `signature` and the + signed-URL ones (`X-Amz-Signature`, `X-Goog-Signature`, …), values passed + as `Redacted`, and credentials in the URL (`https://user:password@…`). + +They're exact names rather than patterns, so fields such as `total_tokens` +stay visible. Plain-text bodies are printed as they are. A call's error is +printed as a copy of its fields, redacted like a body, without an +`HttpError`'s `text` or a `DecodeError`'s `issue`, which the response body +line shows. Defects are printed as they are. + +Good to know: + +- It only reads a response body that's text: JSON, `text/*` other than event + streams, XML or a form, up to 1 MB. effetch has read it already to check + the status or decode it, and Effect caches it. A call without `output` + returns the response itself, so its body isn't read, and stays yours to + stream. Neither is an interrupted call's. +- It writes through Effect's `Console`, so you can provide another one, with + `console.log` and groups only. React Native turns `console.error` into an + error screen. +- In React Native, groups start expanded: Metro's terminal silently drops + `console.groupCollapsed` and everything logged inside it. React Native is + detected by `navigator.product`, which it sets to `"ReactNative"`. + +- It runs when the call is over, so where it sits in the plugin list doesn't + matter: a retried call is one entry, and its duration covers every + attempt. The size comes from `content-length`, or from the body when it's + read. +- When a plugin fails on a response, such as an auth plugin on a 401, that + response is shown with the plugin's error. +- `slow` that isn't a duration throws when the plugin is made. +- The request shown is the one that was sent, after plugins such as `bearer` + changed it. When no response came back, it's the one the call built, + before plugins ran. + ### Plugin order The first plugin is the outermost: it runs first on the way out, and last on the way back. The order above is the one to use: -- `logger` sees the whole call, retries included; +- `logger` sees the whole call, retries included (`consoleLogger` sees the + whole call wherever it is); - each retry gets its own timeout, and asks for the token again; - a timed-out attempt isn't retried. @@ -828,10 +954,37 @@ call's override, so a plugin needs `defaults` to take options. | `onRequest(request, ctx)` | Before the request is sent, in array order. Returns the request to send, or fails. It sees the full URL and the client's headers. | | `onResponse(response, ctx)` | On every response, non-2xx included, in reverse order. Returns the response, or fails. The status check and decoding come after all plugins. | | `onError(error, ctx)` | After a call fails or dies, in array order. It can't change the outcome. | +| `onExit(exit, ctx)` | After every call, whatever the outcome, in array order, after `onError`. It can't change the outcome. | | `wrap(client, options, route)` | Wraps the plugins after it and the network, for retries, timeouts and the like. It can add errors but can't remove them. | `ctx.request` is the request: in `onResponse`, the one this plugin passed on; -in `onError`, the one the call built. +in `onError` and `onExit`, the one the call built. + +`onExit` gets the call's `Exit`: its result (the decoded `output`, or the +response for a call without one), or the cause of its failure, defect or +interruption. Its `ctx` also has the `response` (the one the plugins +returned; when a plugin failed after a response arrived, the last one the +network sent; `undefined` when none arrived) and `durationMs`, the time from +sending the request to decoding the body, retries included. It suits logging +and metrics, and is what `consoleLogger` uses: + +```ts +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import * as Fetch from "@monarcode/effetch"; + +declare const record: (route: string, ok: boolean, durationMs: number) => void; + +export const metrics = Fetch.plugin({ + name: "metrics", + onExit: (exit, ctx) => + Effect.sync(() => record(ctx.route, Exit.isSuccess(exit), ctx.durationMs)), +}); +``` + +The call returns once every `onExit` hook is done, so keep them quick, and +fork anything slow. ### Knowing the route @@ -1470,6 +1623,9 @@ page's URL. Native 0.79 (Expo SDK 53). On older versions, set `resolver.unstable_enablePackageExports = true` in `metro.config.js`. - `FetchHttpClient.layer` uses the global `fetch`. +- Metro's terminal drops `console.groupCollapsed` and everything inside it, + so `consoleLogger` doesn't collapse its groups in React Native (see + [Console logging](#console-logging)). ## Reference @@ -1497,7 +1653,8 @@ page's URL. [endpoints without a client](#defining-endpoints-without-a-client). - `NetworkError`, `HttpError` (and `HttpError.With`), `DecodeError`, `FetchError`, `isFetchError`: [errors](#handling-errors). -- `plugin`, `Plugin` (and the `Plugin.*` helper types), `PluginContext`: +- `plugin`, `Plugin` (and the `Plugin.*` helper types), `PluginContext`, + `PluginExitContext`: [writing plugins](#writing-plugins). - Option and result types, for wrapping the client in your own functions: `RequestOptions`, `NoBodyRequestOptions`, `BaseOptions`, `BodyOptions`, @@ -1512,6 +1669,9 @@ page's URL. - `bearer`, `BearerOptions`, `BearerPlugin`, `TokenSource`, `TokenValue`. - `logger`, `LoggerOptions`, `LoggerPlugin`. +- `consoleLogger`, `ConsoleLoggerOptions`, `ConsoleLoggerSettings`, + `ConsoleLoggerPlugin`, `ConsoleLoggerDetail`, `ConsoleLoggerRedact`, + `ConsoleLoggerCall`, `ConsoleLoggerColor`. - `retry`, `RetryOptions`, `RetryPlugin`. - `timeout`, `TimeoutOptions`, `TimeoutPlugin`, `TimeoutError`. diff --git a/examples/README.md b/examples/README.md index bf483b5..37fe93e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,7 +6,7 @@ Complete programs using effetch. They're typechecked with the package | Example | What it shows | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | [`basic.ts`](basic.ts) | A client in a service, the methods (`get`, `post`, `patch`, `del`) and handling each error. | -| [`builtins.ts`](builtins.ts) | The built-in plugins (`logger`, `retry`, `timeout`, `bearer`) and per-call overrides. | +| [`builtins.ts`](builtins.ts) | The built-in plugins (`logger`, `consoleLogger`, `retry`, `timeout`, `bearer`) and per-call overrides. | | [`plugins.ts`](plugins.ts) | Writing plugins: auth with a service, a header with options, a `wrap` with its own error, error reporting. | | [`endpoints.ts`](endpoints.ts) | The same app as `builtins.ts` with endpoints: one client, its endpoints as the service's methods. | | [`backends.ts`](backends.ts) | Two backends in one file: one client each, shared plugins, and each backend's errors caught next to its calls. | diff --git a/examples/builtins.ts b/examples/builtins.ts index f6534bb..2f97097 100644 --- a/examples/builtins.ts +++ b/examples/builtins.ts @@ -5,7 +5,13 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Fetch from "@monarcode/effetch"; -import { bearer, logger, retry, timeout } from "@monarcode/effetch/plugins"; +import { + bearer, + consoleLogger, + logger, + retry, + timeout, +} from "@monarcode/effetch/plugins"; const Todo = Schema.Struct({ id: Schema.Finite, @@ -17,6 +23,10 @@ const Tokens = Schema.Struct({ accessToken: Schema.String }); const ApiError = Schema.Struct({ code: Schema.String, message: Schema.String }); +// Set by your bundler, such as `__DEV__` in React Native or +// `import.meta.env.DEV` in Vite. +declare const isDevelopment: boolean; + const session: { accessToken: string | undefined } = { accessToken: undefined, }; @@ -26,6 +36,12 @@ export const makeApi = Fetch.make({ defaultError: ApiError, plugins: [ logger({ level: "Info" }), + // Prints every call to the console while developing; does nothing + // in production builds. + consoleLogger({ + enabled: isDevelopment, + slow: "1 second", + }), retry(), timeout({ duration: "10 seconds" }), bearer({ token: () => session.accessToken }), @@ -63,6 +79,8 @@ export const exportTodos = Effect.gen(function* () { plugins: { timeout: { duration: "2 minutes" }, retry: { times: 0 }, + // A large body: print the summary line only. + consoleLogger: { detail: "summary" }, }, }); }); @@ -70,7 +88,9 @@ export const exportTodos = Effect.gen(function* () { export const isHealthy = Effect.gen(function* () { const api = yield* Api; return yield* api - .head("/health", { plugins: { logger: false, retry: false } }) + .head("/health", { + plugins: { logger: false, consoleLogger: false, retry: false }, + }) .pipe( Effect.as(true), Effect.catchTag("HttpError", () => Effect.succeed(false)), diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 754b077..975f3cf 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -57,6 +57,7 @@ npm install --silent --no-audit --no-fund --no-package-lock \ "$tarball" "effect@$effect_version" "typescript@$typescript_version" cat >consumer.ts <<'TS' +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Headers from "effect/http/Headers"; import * as HttpClient from "effect/http/HttpClient"; @@ -67,6 +68,7 @@ import * as Fetch from "@monarcode/effetch"; import { bearer, type BearerPlugin, + consoleLogger, logger, retry, TimeoutError, @@ -140,6 +142,7 @@ const program = Effect.gen(function* () { baseUrl: "https://api.test", plugins: [ logger(), + consoleLogger({ color: false, detail: "summary" }), retry(), timeout({ duration: "5 seconds" }), requestId, @@ -152,9 +155,21 @@ const program = Effect.gen(function* () { output: Todo, plugins: { requestId: { id: "smoke" } }, }); -}).pipe(Effect.provideService(HttpClient.HttpClient, mock)); +}).pipe( + Effect.provideService(HttpClient.HttpClient, mock), + Effect.provideService( + Console.Console, + Object.assign(Object.create(console), { + log: (...args: ReadonlyArray) => printed.push(...args), + }), + ), +); +const printed: Array = []; const todo = await Effect.runPromise(program); +if (!/^GET \/todos\/:id {2}200 OK {2}\d+ ms$/.test(printed.join())) { + throw new Error(`Unexpected console output: ${printed.join()}`); +} const typed: { readonly id: number; readonly title: string } = todo; if (typed.id !== 1 || typed.title !== "Write docs") { throw new Error(`Unexpected todo: ${JSON.stringify(todo)}`); diff --git a/src/Client.ts b/src/Client.ts index 2c88475..e6a6ff1 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -17,6 +17,7 @@ import { makeApplyDefaults, mapClientErrors } from "./internal/pipeline.js"; import { applyPlugins, provideOverrides, + provideResponseBox, provideRoute, } from "./internal/plugins.js"; import { makeCall, type RuntimeOptions } from "./internal/request.js"; @@ -578,7 +579,11 @@ export const make = < const chain = applyPlugins(mapClientErrors(provided), plugins, context); const httpClient = HttpClient.mapRequestInput( HttpClient.transformResponse(chain, (response) => - response.pipe(provideOverrides(undefined), provideRoute(undefined)), + response.pipe( + provideOverrides(undefined), + provideRoute(undefined), + provideResponseBox(undefined), + ), ), applyDefaults, ); diff --git a/src/Plugin.ts b/src/Plugin.ts index 3c258fe..9d134b5 100644 --- a/src/Plugin.ts +++ b/src/Plugin.ts @@ -1,4 +1,5 @@ import type * as Effect from "effect/Effect"; +import type * as Exit from "effect/Exit"; import type * as HttpClient from "effect/http/HttpClient"; import type * as HttpClientRequest from "effect/http/HttpClientRequest"; import type * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -46,8 +47,8 @@ declare const UpstreamServicesTypeId: unique symbol; * * - `request`: in `onRequest`, the request it receives. In `onResponse`, * the request this plugin passed on (plugins after it may have changed it - * further; `response.request` is the one that was sent). In `onError`, - * the request as the call built it, before any plugin ran. + * further; `response.request` is the one that was sent). In `onError` + * and `onExit`, the request as the call built it, before any plugin ran. * - `options`: the plugin's `defaults` merged with this call's override, * `{ ...defaults, ...override }`, where `undefined` values in the * override keep the default. `undefined` for a plugin without @@ -91,6 +92,55 @@ export interface PluginContext { readonly route: string | undefined; } +/** + * What a plugin's `onExit` hook receives besides the call's `Exit`: the + * fields of {@link PluginContext}, and + * + * - `response`: the response the plugin chain returned, whose status was + * checked and whose body was decoded. When a plugin failed after a + * response arrived, the last response the network sent (its body may not + * have been read). `undefined` when no response arrived: a network + * failure, a timeout, or a plugin that failed first. Its `request` is + * the one that was sent, after every plugin's `onRequest`. + * - `durationMs`: how long the call took, in whole milliseconds measured + * with Effect's `Clock`, from when its request entered the plugin chain + * until the status was checked and the body decoded. Retries made by a + * plugin's `wrap` are included. + * + * `route` is always set, as `onExit` doesn't run for requests sent through + * `client.httpClient`. + * + * @example + * ```ts + * import * as Effect from "effect/Effect"; + * import * as Exit from "effect/Exit"; + * import * as Fetch from "@monarcode/effetch"; + * + * const timing = Fetch.plugin({ + * name: "timing", + * onExit: (exit, ctx: Fetch.PluginExitContext) => + * Effect.logDebug( + * `${ctx.route} ${ctx.response?.status ?? "no response"}`, + * Exit.isSuccess(exit) ? "ok" : "failed", + * `${ctx.durationMs}ms`, + * ), + * }); + * ``` + * + * @since 0.1.0 + */ +export interface PluginExitContext extends PluginContext { + /** The call's method and unfilled path, such as `"GET /users/:id"`. */ + readonly route: string; + /** + * The response the plugin chain returned, else the last one the network + * sent, or `undefined` when none arrived. + */ + readonly response: HttpClientResponse.HttpClientResponse | undefined; + /** How long the call took, in whole milliseconds. */ + readonly durationMs: number; +} + /** * A plugin hooks into every call a client makes. * @@ -101,6 +151,9 @@ export interface PluginContext { * and decodes `output` only after the whole plugin chain. * - `onError` observes any failure of the call (after status check and * decoding) and can't change the outcome. + * - `onExit` observes the end of every call, success included, with its + * outcome, its response and how long it took. It can't change the + * outcome either. * - `wrap` wraps the rest of the chain, for retries, timeouts and the like. * It can add errors (`E`) but can't remove the ones from further in. * @@ -153,6 +206,8 @@ export interface Plugin< readonly onResponse?: Plugin.OnResponse | undefined; /** Observes any failure of the call. Can't change the outcome. */ readonly onError?: Plugin.OnError | undefined; + /** Observes the end of every call. Can't change the outcome. */ + readonly onExit?: Plugin.OnExit | undefined; /** Wraps the rest of the chain. */ readonly wrap?: Plugin.Wrap | undefined; } @@ -239,6 +294,45 @@ export declare namespace Plugin { ctx: PluginContext, ) => Effect.Effect; + /** + * The `onExit` hook: runs once per call, when it's over, with the call's + * `Exit`. On success that holds the call's result (the decoded `output`, + * or the response for a call without one); otherwise the cause of its + * failure, defect or interruption. It sees the final outcome, + * `statusErrors` and decoding included, which suits logging and metrics. + * + * It can't fail or change the outcome: if it dies, the defect is logged + * at Debug level and ignored. The call returns once every hook is done, + * so keep hooks quick and fork anything slow. Hooks can't be + * interrupted. + * + * Hooks run in array order, after the `onError` hooks. Where a plugin + * sits in the list doesn't change what its `onExit` sees: retries made + * by a `wrap` all belong to one call. They don't run when the call dies + * before its request is built, for requests sent through + * `client.httpClient`, or for a plugin the call skips. + * + * @example + * ```ts + * import * as Effect from "effect/Effect"; + * import * as Exit from "effect/Exit"; + * import type * as Fetch from "@monarcode/effetch"; + * + * const report: Fetch.Plugin.OnExit = (exit, ctx) => + * Effect.logDebug( + * ctx.route, + * Exit.isSuccess(exit) ? "ok" : "failed", + * `${ctx.durationMs}ms`, + * ); + * ``` + * + * @since 0.1.0 + */ + export type OnExit = ( + exit: Exit.Exit, + ctx: PluginExitContext, + ) => Effect.Effect; + /** * The `wrap` hook: wraps the rest of the chain (`client`). Its error and * service types are opaque placeholders, so a `wrap` can add errors and @@ -314,6 +408,8 @@ export declare namespace Plugin { readonly onResponse?: OnResponse | undefined; /** The plugin's `onError` hook, if any. */ readonly onError?: OnError | undefined; + /** The plugin's `onExit` hook, if any. */ + readonly onExit?: OnExit | undefined; /** The plugin's `wrap` hook, if any. */ readonly wrap?: | (( @@ -415,6 +511,7 @@ export declare namespace Plugin { RError, RWrap, Opts, + RExit = never, > { /** Unique within a client. Used as the key for per-request overrides. */ readonly name: Name; @@ -430,6 +527,8 @@ export declare namespace Plugin { | undefined; /** Observes any failure of the call. Can't change the outcome. */ readonly onError?: OnError, RError> | undefined; + /** Observes the end of every call. Can't change the outcome. */ + readonly onExit?: OnExit, RExit> | undefined; /** Wraps the rest of the chain. */ readonly wrap?: Wrap, EWrap, RWrap> | undefined; } @@ -577,6 +676,7 @@ export function plugin< RError = never, RWrap = never, Opts = undefined, + RExit = never, >( definition: Plugin.Definition< Name, @@ -587,13 +687,18 @@ export function plugin< RResponse, RError, RWrap, - Opts + Opts, + RExit >, ): Plugin< Name, Settled>, Settled< - RRequest | RResponse | RError | Exclude + | RRequest + | RResponse + | RError + | RExit + | Exclude >, Settled >; diff --git a/src/index.ts b/src/index.ts index 7e002f1..87c929b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,4 +49,4 @@ export type { } from "./Options.js"; export type { PathParamNames, PathParams, PathParamValue } from "./Path.js"; export { plugin } from "./Plugin.js"; -export type { Plugin, PluginContext } from "./Plugin.js"; +export type { Plugin, PluginContext, PluginExitContext } from "./Plugin.js"; diff --git a/src/internal/plugins.ts b/src/internal/plugins.ts index cf6daea..97c64b3 100644 --- a/src/internal/plugins.ts +++ b/src/internal/plugins.ts @@ -1,9 +1,11 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import type * as Exit from "effect/Exit"; import * as HttpClient from "effect/http/HttpClient"; import type * as HttpClientRequest from "effect/http/HttpClientRequest"; import type * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as Predicate from "effect/Predicate"; import type { NetworkError } from "../Errors.js"; import type { Plugin } from "../Plugin.js"; @@ -51,19 +53,64 @@ export const provideRoute = ( const currentRoute = Effect.service(CurrentRoute); +/** + * Where a call with `onExit` hooks keeps the response its `onExit` hooks + * get: the last one the network sent, or the one the plugin chain returned + * once it has. Each network attempt clears it first, so after a retry it's + * the last attempt's. + */ +export interface ResponseBox { + current: HttpClientResponse.HttpClientResponse | undefined; +} + +/** + * The {@link ResponseBox} of the call being run, if it has `onExit` hooks. + * Provided around each call (`undefined` without hooks, and for + * `client.httpClient`), so a call made inside another doesn't write to its + * box. + */ +export const CurrentResponseBox: Context.Reference = + Context.Reference( + "@monarcode/effetch/CurrentResponseBox", + { defaultValue: () => undefined }, + ); + +export const provideResponseBox = ( + box: ResponseBox | undefined, +): ((self: Effect.Effect) => Effect.Effect) => + Effect.provideService(CurrentResponseBox, box); + +/** + * `client`'s `execute`, recording each response in the call's + * {@link ResponseBox}, if it has one. + */ +const recordResponses = + (client: HttpClient.HttpClient.With) => + ( + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => + Effect.flatMap(Effect.service(CurrentResponseBox), (box) => { + if (box === undefined) { + return client.execute(request); + } + box.current = undefined; + return Effect.tap(client.execute(request), (response) => + Effect.sync(() => { + box.current = response; + }), + ); + }); + const overrideOf = (overrides: OverrideMap, name: string): unknown => hasOwn(overrides, name) ? overrides[name] : undefined; -const isRecord = (u: unknown): u is { readonly [key: string]: unknown } => - typeof u === "object" && u !== null && !Array.isArray(u); - /** * `{ ...defaults, ...override }`, leaving out the override's `undefined` * values so they keep their default. Only object options can be * overridden; anything else keeps `defaults`. */ export const mergeOptions = (defaults: unknown, override: unknown): unknown => - isRecord(defaults) && isRecord(override) + Predicate.isObject(defaults) && Predicate.isObject(override) ? { ...defaults, ...Object.fromEntries( @@ -97,7 +144,7 @@ export const mergeOverrides = ( } return [ name, - isRecord(base) && isRecord(override) + Predicate.isObject(base) && Predicate.isObject(override) ? mergeOptions(base, override) : override, ]; @@ -208,45 +255,43 @@ export const applyPlugins = ( ): HttpClient.HttpClient.With => { const execute = plugins.reduceRight( (inner, plugin) => layer(plugin, inner), - executeOf(client), + recordResponses(client), ); return fromExecute((request) => withContext(context)(execute(request))); }; /** - * Runs the `onError` hooks, in array order, for a call that failed or died - * after `request` was built. Interruption isn't a failure, so it runs none. - * Plugins skipped by the call are left out. A hook that dies is logged at - * Debug level and never changes the call's outcome. + * Runs one kind of observing hook (`onError` or `onExit`) of each plugin, + * in array order, leaving out plugins the call skips. A hook that dies is + * logged at Debug level and never changes the call's outcome. */ -export const runErrorHooks = ( +const runHooks = ( plugins: ReadonlyArray, context: Context.Context, - request: HttpClientRequest.HttpClientRequest, - cause: Cause.Cause, + name: "onError" | "onExit", + run: ( + hook: H, + options: unknown, + route: string | undefined, + ) => Effect.Effect, ): Effect.Effect => { - if (!Cause.hasFails(cause) && !Cause.hasDies(cause)) { - return Effect.void; - } - const error = Cause.squash(cause); const hooks = Effect.gen(function* () { const overrides = yield* Effect.service(CurrentOverrides); const route = yield* currentRoute; yield* Effect.forEach( plugins, (plugin) => { + const hook = plugin[name] as H | undefined; const override = overrideOf(overrides, plugin.name); - if (plugin.onError === undefined || override === false) { + if (hook === undefined || override === false) { return Effect.void; } const options = mergeOptions(plugin.defaults, override); - return Effect.catchCause( - plugin.onError(error, { request, options, route }), - (failure) => - Effect.logDebug( - `effetch: the onError hook of plugin "${plugin.name}" failed`, - failure, - ), + return Effect.catchCause(run(hook, options, route), (failure) => + Effect.logDebug( + `effetch: the ${name} hook of plugin "${plugin.name}" failed`, + failure, + ), ); }, { discard: true }, @@ -254,3 +299,51 @@ export const runErrorHooks = ( }); return withContext(context)(hooks); }; + +/** + * Runs the `onError` hooks for a call that failed or died after `request` + * was built. Interruption isn't a failure, so it runs none. + */ +export const runErrorHooks = ( + plugins: ReadonlyArray, + context: Context.Context, + request: HttpClientRequest.HttpClientRequest, + cause: Cause.Cause, +): Effect.Effect => { + if (!Cause.hasFails(cause) && !Cause.hasDies(cause)) { + return Effect.void; + } + const error = Cause.squash(cause); + return runHooks>( + plugins, + context, + "onError", + (hook, options, route) => hook(error, { request, options, route }), + ); +}; + +/** What the `onExit` hooks receive besides the `Exit`, options and route. */ +export interface ExitDetails { + readonly request: HttpClientRequest.HttpClientRequest; + readonly response: HttpClientResponse.HttpClientResponse | undefined; + readonly durationMs: number; +} + +/** `true` if any plugin has an `onExit` hook. */ +export const hasExitHooks = (plugins: ReadonlyArray): boolean => + plugins.some((plugin) => plugin.onExit !== undefined); + +/** Runs the `onExit` hooks for a call that ended after `request` was built. */ +export const runExitHooks = ( + plugins: ReadonlyArray, + context: Context.Context, + exit: Exit.Exit, + details: ExitDetails, + route: string, +): Effect.Effect => + runHooks>( + plugins, + context, + "onExit", + (hook, options) => hook(exit, { ...details, options, route }), + ); diff --git a/src/internal/request.ts b/src/internal/request.ts index 77ccb3f..b497ade 100644 --- a/src/internal/request.ts +++ b/src/internal/request.ts @@ -1,3 +1,4 @@ +import * as Clock from "effect/Clock"; import type * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Headers from "effect/http/Headers"; @@ -17,10 +18,14 @@ import { encodeBody } from "./body.js"; import { interpolatePath } from "./path.js"; import type { ApplyDefaults } from "./pipeline.js"; import { + hasExitHooks, type OverrideMap, provideOverrides, + provideResponseBox, provideRoute, + type ResponseBox, runErrorHooks, + runExitHooks, } from "./plugins.js"; import { toUrlParams } from "./query.js"; import { checkStatus, decodeOutput } from "./response.js"; @@ -84,14 +89,13 @@ const buildRequest = ( return request; }); -const execute = ( +/** Checks the response's status, then decodes its body with `output`. */ +const handle = ( state: RequesterState, - request: HttpClientRequest.HttpClientRequest, + response: HttpClientResponse.HttpClientResponse, options: RuntimeOptions, ): Effect.Effect => Effect.gen(function* () { - const response: HttpClientResponse.HttpClientResponse = - yield* state.chain.execute(request); const checked = yield* checkStatus( response, options.error ?? state.errorSchema, @@ -102,21 +106,91 @@ const execute = ( : yield* decodeOutput(checked, options.output); }); +/** + * Sends `request` through the plugin chain, then handles the response. + * With a `box`, the response the chain returns is recorded in it. + */ +const execute = ( + state: RequesterState, + request: HttpClientRequest.HttpClientRequest, + options: RuntimeOptions, + box?: ResponseBox | undefined, +): Effect.Effect => + Effect.flatMap(state.chain.execute(request), (response) => { + if (box !== undefined) { + box.current = response; + } + return handle(state, response, options); + }); + +/** + * Runs `call` (the call's {@link execute}, with its `onError` hooks), then + * the `onExit` hooks, whatever the outcome. The duration stops when the + * response is handled, so the `onError` hooks don't count. + */ +const withExitHooks = ( + state: RequesterState, + request: HttpClientRequest.HttpClientRequest, + route: string, + run: ( + execute: Effect.Effect, + ) => Effect.Effect, + call: (box: ResponseBox) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const box: ResponseBox = { current: undefined }; + const start = yield* Clock.currentTimeMillis; + let end = start; + const timed = call(box).pipe( + provideResponseBox(box), + Effect.onExit(() => + Effect.map(Clock.currentTimeMillis, (now) => { + end = now; + }), + ), + ); + return yield* Effect.onExit(run(timed), (exit) => + runExitHooks( + state.plugins, + state.context, + exit, + { + request, + response: box.current, + durationMs: Math.round(end - start), + }, + route, + ), + ); + }); + const send = ( state: RequesterState, method: HttpMethod.HttpMethod, path: string, options: RuntimeOptions, -): Effect.Effect => - Effect.gen(function* () { +): Effect.Effect => { + const route = `${method} ${path}`; + return Effect.gen(function* () { const request = yield* buildRequest(state, method, path, options); - return yield* Effect.tapCause(execute(state, request, options), (cause) => - runErrorHooks(state.plugins, state.context, request, cause), - ); + const withErrorHooks = ( + effect: Effect.Effect, + ) => + Effect.tapCause(effect, (cause) => + runErrorHooks(state.plugins, state.context, request, cause), + ); + return yield* hasExitHooks(state.plugins) + ? withExitHooks(state, request, route, withErrorHooks, (box) => + execute(state, request, options, box), + ) + : withErrorHooks(execute(state, request, options)); }).pipe( provideOverrides(options.plugins), - provideRoute(`${method} ${path}`), + provideRoute(route), + // A call inside another (from a plugin) doesn't record in its box. + provideResponseBox(undefined), ); +}; export type Call = ( path: string, diff --git a/src/internal/statusErrors.ts b/src/internal/statusErrors.ts index eca44c7..ed009e4 100644 --- a/src/internal/statusErrors.ts +++ b/src/internal/statusErrors.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import type * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import * as SchemaAST from "effect/SchemaAST"; @@ -111,9 +112,6 @@ export const tagOf = (schema: AnySchema): string | undefined => { return tag ?? undefined; }; -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - /** * Decodes an error body with one `statusErrors` schema: some error when it * matches, none when it doesn't. Backends don't send a `_tag`, so when the @@ -126,7 +124,7 @@ export const decodeStatusError = ( ): Effect.Effect, never, S["DecodingServices"]> => { const tag = tagOf(schema); const body = - tag !== undefined && isRecord(json) && !hasOwn(json, "_tag") + tag !== undefined && Predicate.isObject(json) && !hasOwn(json, "_tag") ? { ...json, _tag: tag } : json; return Effect.option(decodeJson(schema)(body)); diff --git a/src/plugins/consoleLogger.ts b/src/plugins/consoleLogger.ts new file mode 100644 index 0000000..0b6ccbc --- /dev/null +++ b/src/plugins/consoleLogger.ts @@ -0,0 +1,1294 @@ +import * as Cause from "effect/Cause"; +import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Headers from "effect/http/Headers"; +import type * as HttpBody from "effect/http/HttpBody"; +import type * as HttpClientRequest from "effect/http/HttpClientRequest"; +import type * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; + +import { type Plugin, type PluginExitContext, plugin } from "../Plugin.js"; + +/** + * How much {@link consoleLogger} prints for each call. + * + * - `"summary"`: one line, such as + * `✗ POST /users/:id/roles 400 Bad Request 120 ms → InvalidRole`. + * - `"headers"`: that line opens a group holding the full URL, the request + * and response headers, and the error. + * - `"full"`: the request and response bodies too. + * + * @example + * ```ts + * import type { ConsoleLoggerDetail } from "@monarcode/effetch/plugins"; + * + * const detail: ConsoleLoggerDetail = "headers"; + * ``` + * + * @since 0.1.0 + */ +export type ConsoleLoggerDetail = "summary" | "headers" | "full"; + +/** + * The options of {@link consoleLogger} that calls can override, with + * `plugins: { consoleLogger: { ... } }`. See {@link ConsoleLoggerOptions} + * for what each does. + * + * @example + * ```ts + * import type { ConsoleLoggerSettings } from "@monarcode/effetch/plugins"; + * + * const quiet: Partial = { + * detail: "summary", + * only: "errors", + * }; + * ``` + * + * @since 0.1.0 + */ +export interface ConsoleLoggerSettings { + /** How much to print for each call. */ + readonly detail: ConsoleLoggerDetail; + /** Which calls to print: `"all"`, or only the failed ones (`"errors"`). */ + readonly only: "all" | "errors"; + /** + * Whether each call's group starts collapsed. `false` by default in + * React Native, where Metro's terminal drops collapsed groups. + */ + readonly collapsed: boolean; + /** Calls that take at least this long are flagged; `false` for none. */ + readonly slow: Duration.Input | false; + /** Body text longer than this many characters is cut. */ + readonly maxBodyLength: number; +} + +/** + * Names to redact on top of {@link consoleLogger}'s defaults. Strings match + * a name ignoring case, `-` and `_`, so `"accessToken"` also matches + * `access_token` and `Access-Token`. A `RegExp` is tested against the name + * as it is. + * + * @example + * ```ts + * import type { ConsoleLoggerRedact } from "@monarcode/effetch/plugins"; + * + * const redact: ConsoleLoggerRedact = { + * headers: ["x-session"], + * query: ["invite"], + * body: ["otp", /^card/i], + * }; + * ``` + * + * @since 0.1.0 + */ +export interface ConsoleLoggerRedact { + /** Request and response header names. */ + readonly headers?: ReadonlyArray | undefined; + /** Query parameter names. */ + readonly query?: ReadonlyArray | undefined; + /** + * Keys in JSON bodies (at any depth), and field names in form bodies, + * of requests and responses. + */ + readonly body?: ReadonlyArray | undefined; +} + +/** + * The call {@link ConsoleLoggerOptions.filter} decides on. + * + * - `route`: the call's method and unfilled path, such as + * `"GET /users/:id"`. + * - `request`: the request that was sent, after every plugin changed it. + * Without a response, it's the request as the call built it, before + * plugins ran. + * - `response`: the response, or `undefined` when there was none. + * - `exit`: the call's outcome. + * - `durationMs`: how long the call took, in milliseconds. + * + * @example + * ```ts + * import type { ConsoleLoggerCall } from "@monarcode/effetch/plugins"; + * + * const notHealthChecks = (call: ConsoleLoggerCall): boolean => + * call.route !== "GET /health"; + * ``` + * + * @since 0.1.0 + */ +export interface ConsoleLoggerCall { + /** The call's method and unfilled path, such as `"GET /users/:id"`. */ + readonly route: string; + /** The request that was sent. */ + readonly request: HttpClientRequest.HttpClientRequest; + /** The response, or `undefined` when there was none. */ + readonly response: HttpClientResponse.HttpClientResponse | undefined; + /** The call's outcome. */ + readonly exit: Exit.Exit; + /** How long the call took, in milliseconds. */ + readonly durationMs: number; +} + +/** + * How {@link consoleLogger} colors its output. + * + * - `"auto"`: `"css"` where there's a `document` (browsers), else `false`. + * - `"css"`: `%c` styles, for browser devtools and React Native DevTools. + * - `"ansi"`: terminal escape codes, for Node, Bun and Deno. + * - `false`: plain text. + * + * @example + * ```ts + * import type { ConsoleLoggerColor } from "@monarcode/effetch/plugins"; + * + * const color: ConsoleLoggerColor = "ansi"; + * ``` + * + * @since 0.1.0 + */ +export type ConsoleLoggerColor = "auto" | "css" | "ansi" | false; + +/** + * Options for {@link consoleLogger}. All are optional. + * + * - `enabled`: `false` makes the plugin do nothing, keeping its type, so + * it can stay in the plugin list in production. Defaults to `true`. + * - `detail`: `"summary"`, `"headers"` or `"full"` (the default). See + * {@link ConsoleLoggerDetail}. + * - `only`: `"all"` calls (the default), or `"errors"` for failed ones + * only. An interrupted call isn't a failed one. + * - `collapsed`: whether groups start collapsed. Defaults to `true`, and + * to `false` in React Native: Metro's terminal drops + * `console.groupCollapsed` and everything logged inside it. Node, Bun + * and Deno print collapsed groups expanded. + * - `slow`: calls that take at least this long are flagged with `⚠` and + * `(slow)`. Defaults to `false`, flagging none. + * - `maxBodyLength`: body text longer than this many characters is cut. + * A JSON body within it is printed as an object, for devtools to + * expand; a longer one as its cut text. Defaults to `10_000`. + * - `redact`: names to redact on top of the defaults, or `false` to + * redact nothing. See {@link ConsoleLoggerRedact}. + * - `filter`: prints only the calls it returns `true` for. + * - `color`: see {@link ConsoleLoggerColor}. Defaults to `"auto"`. + * + * Calls can override `detail`, `only`, `collapsed`, `slow` and + * `maxBodyLength` (see {@link ConsoleLoggerSettings}). + * + * @example + * ```ts + * import type { ConsoleLoggerOptions } from "@monarcode/effetch/plugins"; + * + * const options: ConsoleLoggerOptions = { + * detail: "headers", + * slow: "1 second", + * redact: { body: ["otp"] }, + * filter: (call) => call.route !== "GET /health", + * }; + * ``` + * + * @since 0.1.0 + */ +export interface ConsoleLoggerOptions { + /** `false` makes the plugin do nothing. Defaults to `true`. */ + readonly enabled?: boolean | undefined; + /** How much to print for each call. Defaults to `"full"`. */ + readonly detail?: ConsoleLoggerDetail | undefined; + /** Which calls to print. Defaults to `"all"`. */ + readonly only?: "all" | "errors" | undefined; + /** + * Whether groups start collapsed. Defaults to `true`, and to `false` in + * React Native, where Metro's terminal drops collapsed groups. + */ + readonly collapsed?: boolean | undefined; + /** Flags calls that take at least this long. Defaults to `false`. */ + readonly slow?: Duration.Input | false | undefined; + /** Body text longer than this is cut. Defaults to `10_000`. */ + readonly maxBodyLength?: number | undefined; + /** Names to redact on top of the defaults, or `false` for none. */ + readonly redact?: ConsoleLoggerRedact | false | undefined; + /** Prints only the calls it returns `true` for. */ + readonly filter?: ((call: ConsoleLoggerCall) => boolean) | undefined; + /** How to color the output. Defaults to `"auto"`. */ + readonly color?: ConsoleLoggerColor | undefined; +} + +/** + * The plugin made by {@link consoleLogger}. Calls can skip it + * (`plugins: { consoleLogger: false }`) or override its + * {@link ConsoleLoggerSettings}. + * + * @example + * ```ts + * import { + * consoleLogger, + * type ConsoleLoggerPlugin, + * } from "@monarcode/effetch/plugins"; + * + * const log: ConsoleLoggerPlugin = consoleLogger({ detail: "summary" }); + * ``` + * + * @since 0.1.0 + */ +export type ConsoleLoggerPlugin = Plugin< + "consoleLogger", + never, + never, + ConsoleLoggerSettings +>; + +/** Matches names, ignoring case, `-` and `_` for strings. */ +type Matcher = (name: string) => boolean; + +/** The fixed parts of a logger: what {@link consoleLogger} was given. */ +interface Config { + readonly color: "css" | "ansi" | false; + /** `undefined` when nothing is redacted. */ + readonly redact: + | { + readonly headers: ReadonlyArray; + readonly query: Matcher; + readonly body: Matcher; + } + | undefined; + readonly filter: ((call: ConsoleLoggerCall) => boolean) | undefined; +} + +const redactedText = ""; + +/** + * Names of credentials, matched ignoring case, `-` and `_` (so `"apiKey"` + * also matches `api_key` and `API-KEY`). Exact names rather than patterns + * such as `/token/`, which would hide fields like `total_tokens`. + */ +const credentials: ReadonlyArray = [ + "token", + "accessToken", + "refreshToken", + "idToken", + "authToken", + "apiToken", + "sessionToken", + "csrfToken", + "xsrfToken", + "secret", + "clientSecret", + "apiSecret", + "apiKey", + "privateKey", + /password/i, +]; + +/** + * Header names redacted on top of Effect's `Headers.CurrentRedactedNames` + * (`authorization`, `cookie`, `set-cookie` and `x-api-key` by default). + */ +const defaultHeaders: ReadonlyArray = [ + "proxy-authorization", + "x-auth-token", + "x-access-token", + "x-refresh-token", + "x-session-token", + "x-csrf-token", + "x-xsrf-token", + "x-client-secret", + "x-amz-security-token", +]; + +const defaultQuery: ReadonlyArray = [ + ...credentials, + "key", + "sig", + "signature", + "X-Amz-Signature", + "X-Amz-Credential", + "X-Amz-Security-Token", + "X-Goog-Signature", + "X-Goog-Credential", + "AWSAccessKeyId", +]; + +const defaultBody: ReadonlyArray = credentials; + +const normalize = (name: string): string => + name.toLowerCase().replace(/[-_]/g, ""); + +const matcher = (patterns: ReadonlyArray): Matcher => { + const names = new Set(); + const regexps: Array = []; + for (const pattern of patterns) { + if (Predicate.isString(pattern)) { + names.add(normalize(pattern)); + } else { + regexps.push(pattern); + } + } + return (name) => + names.has(normalize(name)) || + regexps.some((pattern) => name.search(pattern) !== -1); +}; + +const noMatch: Matcher = () => false; + +// --- Formatting ------------------------------------------------------------- + +type Tone = "plain" | "bold" | "dim" | "green" | "cyan" | "yellow" | "red"; + +interface Segment { + readonly text: string; + readonly tone: Tone; +} + +const segment = (text: string, tone: Tone = "plain"): Segment => ({ + text, + tone, +}); + +const css: Record = { + plain: "", + bold: "font-weight: bold", + dim: "color: #888", + green: "color: #2e9d4f", + cyan: "color: #2b8cbe", + yellow: "color: #c47f00", + red: "color: #d93025", +}; + +const ansi: Record = { + plain: ["", ""], + bold: ["\u001b[1m", "\u001b[22m"], + dim: ["\u001b[2m", "\u001b[22m"], + green: ["\u001b[32m", "\u001b[39m"], + cyan: ["\u001b[36m", "\u001b[39m"], + yellow: ["\u001b[33m", "\u001b[39m"], + red: ["\u001b[31m", "\u001b[39m"], +}; + +/** + * `text` safe to use as a console format string: a `%` that would start a + * substitution, such as `%d` in a path, is doubled. + */ +const literal = (text: string): string => + /%[sdifoOcj%]/.test(text) ? text.replace(/%/g, "%%") : text; + +/** The console arguments that print `segments` in `color`. */ +const render = ( + segments: ReadonlyArray, + color: Config["color"], +): Array => { + if (color === "css") { + return [ + segments.map((part) => `%c${literal(part.text)}`).join(""), + ...segments.map((part) => css[part.tone]), + ]; + } + return [ + segments + .map((part) => { + const text = literal(part.text); + if (color === false) { + return text; + } + const [open, close] = ansi[part.tone]; + return open + text + close; + }) + .join(""), + ]; +}; + +const statusTexts: Readonly> = { + 200: "OK", + 201: "Created", + 202: "Accepted", + 204: "No Content", + 206: "Partial Content", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 412: "Precondition Failed", + 413: "Content Too Large", + 415: "Unsupported Media Type", + 422: "Unprocessable Content", + 423: "Locked", + 428: "Precondition Required", + 429: "Too Many Requests", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", +}; + +const statusTone = (status: number): Tone => + status < 300 + ? "green" + : status < 400 + ? "cyan" + : status < 500 + ? "yellow" + : "red"; + +const formatDuration = (ms: number): string => + ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`; + +const formatBytes = (bytes: number): string => + bytes < 1000 + ? `${bytes} B` + : bytes < 1_000_000 + ? `${(bytes / 1000).toFixed(1)} kB` + : `${(bytes / 1_000_000).toFixed(1)} MB`; + +/** `text` on one line, cut to `max` characters. */ +const oneLine = (text: string, max = 160): string => { + const line = text.replace(/\s+/g, " ").trim(); + return line.length > max ? `${line.slice(0, max)}…` : line; +}; + +/** `text` cut to `max` characters, saying how many were left out. */ +const cut = (text: string, max: number): string => + text.length > max + ? `${text.slice(0, max)}… (${text.length - max} more characters)` + : text; + +// --- Outcome ---------------------------------------------------------------- + +type Outcome = + | { readonly _tag: "Success" } + | { readonly _tag: "Interrupted" } + | { + readonly _tag: "Failure"; + readonly error: unknown; + readonly defect: boolean; + }; + +const outcomeOf = (exit: Exit.Exit): Outcome => { + if (Exit.isSuccess(exit)) { + return { _tag: "Success" }; + } + if (Cause.hasInterruptsOnly(exit.cause)) { + return { _tag: "Interrupted" }; + } + return { + _tag: "Failure", + error: Cause.squash(exit.cause), + defect: !Cause.hasFails(exit.cause), + }; +}; + +const nameOf = (error: unknown): string | undefined => + Predicate.hasProperty(error, "_tag") && Predicate.isString(error._tag) + ? error._tag + : error instanceof Error + ? error.name + : undefined; + +const messageOf = (error: unknown): string => + Predicate.isString(error) + ? error + : Predicate.hasProperty(error, "message") && + Predicate.isString(error.message) + ? error.message + : ""; + +/** + * A short description of `error` for the summary line: its tag or name, + * and its message without the `" : "` that effetch's own + * errors start with. An `HttpError` is its tag alone, as the line shows + * the status, and a `NetworkError` shows its cause's message. + */ +const describeError = (error: unknown, method: string): string => { + const name = nameOf(error); + if (name === "HttpError") { + return name; + } + const source = + name === "NetworkError" && Predicate.hasProperty(error, "cause") + ? error.cause + : error; + const prefix = new RegExp(`^${method} \\S+: `); + const message = oneLine(messageOf(source).replace(prefix, "")); + if (name === undefined) { + return message === "" ? "unknown error" : message; + } + return message === "" || message === name ? name : `${name}: ${message}`; +}; + +// --- Request ---------------------------------------------------------------- + +/** The `user:password@` part of an absolute URL, after its scheme. */ +const userinfo = /^([a-z][a-z\d+.-]*:\/\/)[^/?#]*@/i; + +/** Decodes a query key, or returns it as it is when it isn't valid. */ +const decodeKey = (key: string): string => { + try { + return decodeURIComponent(key.replace(/\+/g, " ")); + } catch { + return key; + } +}; + +/** + * The request's full URL: its `url`, query string and hash, with redacted + * query values replaced. Built with string operations, not `new URL()`. + */ +const fullUrl = ( + request: HttpClientRequest.HttpClientRequest, + redact: Matcher, +): string => { + const hashAt = request.url.indexOf("#"); + const beforeHash = + hashAt === -1 ? request.url : request.url.slice(0, hashAt); + const queryAt = beforeHash.indexOf("?"); + const path = queryAt === -1 ? beforeHash : beforeHash.slice(0, queryAt); + const parts = + queryAt === -1 + ? [] + : beforeHash + .slice(queryAt + 1) + .split("&") + .filter((part) => part !== ""); + for (const [key, value] of request.urlParams.params) { + // A `Redacted` value (Effect 4.1) isn't a string. + const shown: unknown = value; + parts.push( + `${encodeURIComponent(key)}=${ + Predicate.isString(shown) ? encodeURIComponent(shown) : redactedText + }`, + ); + } + const query = parts.map((part) => { + const equals = part.indexOf("="); + const key = equals === -1 ? part : part.slice(0, equals); + return redact(decodeKey(key)) ? `${key}=${redactedText}` : part; + }); + const origin = + redact === noMatch + ? path + : path.replace( + userinfo, + (_, scheme: string) => `${scheme}${redactedText}@`, + ); + const hash = + Option.getOrUndefined(request.hash) ?? + (hashAt === -1 ? undefined : request.url.slice(hashAt + 1)); + return ( + origin + + (query.length === 0 ? "" : `?${query.join("&")}`) + + (hash === undefined || hash === "" ? "" : `#${hash}`) + ); +}; + +const headersObject = ( + headers: Headers.Headers, + redact: Matcher, +): Record => + Object.fromEntries( + Object.entries(headers).map(([name, value]) => [ + name, + redact(name) ? redactedText : value, + ]), + ); + +/** `value` with the values of matching keys replaced, at any depth. */ +const redactValue = (value: unknown, redact: Matcher, depth = 0): unknown => { + if (depth > 64) { + // Too deep to check, so not shown. + return "<…>"; + } + if (Array.isArray(value)) { + return value.map((item) => redactValue(item, redact, depth + 1)); + } + if (Predicate.isObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + redact(key) ? redactedText : redactValue(item, redact, depth + 1), + ]), + ); + } + return value; +}; + +/** The media type of a `content-type`, lowercased, without parameters. */ +const mediaTypeOf = (contentType: string | undefined): string => + (contentType ?? "").split(";", 1)[0]?.trim().toLowerCase() ?? ""; + +const isJson = (mediaType: string): boolean => + mediaType === "application/json" || mediaType.endsWith("+json"); + +const isForm = (mediaType: string): boolean => + mediaType === "application/x-www-form-urlencoded"; + +/** + * `true` for bodies worth reading as text. Event streams are left out: + * they may never end. + */ +const isText = (mediaType: string): boolean => + (mediaType.startsWith("text/") && mediaType !== "text/event-stream") || + isJson(mediaType) || + isForm(mediaType) || + mediaType === "application/xml" || + mediaType.endsWith("+xml"); + +/** Form fields as an object: a field given more than once is a list. */ +const fieldsObject = ( + fields: Iterable, + redact: Matcher, +): Record => { + const grouped = new Map>(); + for (const [name, value] of fields) { + const shown = redact(name) ? redactedText : value; + const values = grouped.get(name); + if (values === undefined) { + grouped.set(name, [shown]); + } else { + values.push(shown); + } + } + return Object.fromEntries( + Array.from(grouped, ([name, values]) => [ + name, + values.length === 1 ? values[0] : values, + ]), + ); +}; + +const parseForm = (text: string): Array => + text + .split("&") + .filter((part) => part !== "") + .map((part) => { + const equals = part.indexOf("="); + return equals === -1 + ? [decodeKey(part), ""] + : [ + decodeKey(part.slice(0, equals)), + decodeKey(part.slice(equals + 1)), + ]; + }); + +/** + * A text body as it's printed: JSON and form bodies as objects (redacted) + * when their text fits in `max`, anything else as its text, cut to `max`. + */ +const showText = ( + text: string, + mediaType: string, + max: number, + redact: Matcher, +): unknown => { + let value: unknown; + if (isJson(mediaType)) { + try { + value = redactValue(JSON.parse(text), redact); + } catch { + return cut(text, max); + } + } else if (isForm(mediaType)) { + value = fieldsObject(parseForm(text), redact); + } else { + return cut(text, max); + } + const json = JSON.stringify(value) ?? ""; + return json.length > max ? cut(json, max) : value; +}; + +/** + * Bodies larger than this many bytes (or 4 × `maxBodyLength`, if larger) + * aren't decoded and parsed just to print the start of them. + */ +const parseLimit = (max: number): number => Math.max(1_000_000, max * 4); + +/** A `Blob` or `File`, described by its name, size and type. */ +const describeBlob = (blob: unknown): string => { + const name = + Predicate.hasProperty(blob, "name") && Predicate.isString(blob.name) + ? blob.name + : "blob"; + const size = + Predicate.hasProperty(blob, "size") && typeof blob.size === "number" + ? formatBytes(blob.size) + : undefined; + const type = + Predicate.hasProperty(blob, "type") && + Predicate.isString(blob.type) && + blob.type !== "" + ? blob.type + : undefined; + return `<${[name, size, type].filter(Predicate.isNotUndefined).join(", ")}>`; +}; + +const isBlob = (value: unknown): boolean => + typeof Blob !== "undefined" && value instanceof Blob; + +const describeObject = (value: unknown): string => + isBlob(value) + ? describeBlob(value) + : `<${ + Predicate.isObjectKeyword(value) + ? (Object.getPrototypeOf(value)?.constructor?.name ?? "object") + : typeof value + }>`; + +const describeBinary = (mediaType: string, bytes: number | undefined): string => + `<${[mediaType === "" ? "binary" : mediaType, bytes === undefined ? undefined : formatBytes(bytes)].filter(Predicate.isNotUndefined).join(", ")}>`; + +/** The request body as it's printed, or `undefined` for none. */ +const showRequestBody = ( + body: HttpBody.HttpBody, + max: number, + redact: Matcher, +): unknown => { + switch (body._tag) { + case "Empty": + return undefined; + case "Uint8Array": { + const mediaType = mediaTypeOf(body.contentType); + if ( + !isText(mediaType) || + typeof TextDecoder === "undefined" || + body.contentLength > parseLimit(max) + ) { + return describeBinary(mediaType, body.contentLength); + } + return showText( + body.text ?? new TextDecoder().decode(body.body), + mediaType, + max, + redact, + ); + } + case "Raw": + return Predicate.isString(body.body) && + body.body.length <= parseLimit(max) + ? showText(body.body, mediaTypeOf(body.contentType), max, redact) + : describeObject(body.body); + case "FormData": { + const { formData } = body; + if (!Predicate.isFunction(formData.forEach)) { + return ""; + } + const fields: Array = []; + formData.forEach((value, name) => { + fields.push([ + name, + Predicate.isString(value) ? value : describeBlob(value), + ]); + }); + return fieldsObject(fields, redact); + } + case "Stream": + return ``; + } +}; + +// --- Response --------------------------------------------------------------- + +interface ResponseBody { + /** The body as it's printed, or `undefined` to print none. */ + readonly shown: unknown; + /** The body's size in bytes, when it was read. */ + readonly bytes: number | undefined; +} + +const contentLengthOf = ( + response: HttpClientResponse.HttpClientResponse, +): number | undefined => { + const header = response.headers["content-length"]?.trim(); + const length = + header === undefined || !/^\d+$/.test(header) + ? Number.NaN + : Number(header); + return Number.isSafeInteger(length) ? length : undefined; +}; + +/** + * Reads the response body to print it. The body is only read when it's + * text and the call didn't return the response itself (a call without + * `output`, whose caller may stream it). Effect caches it, so effetch + * reading it before, or the caller after, is unaffected. + */ +const readResponseBody = ( + response: HttpClientResponse.HttpClientResponse, + exit: Exit.Exit, + max: number, + redact: Matcher, +): Effect.Effect => { + if ( + response.request.method === "HEAD" || + response.status === 204 || + response.status === 205 || + response.status === 304 || + contentLengthOf(response) === 0 + ) { + return Effect.succeed({ shown: undefined, bytes: undefined }); + } + if (Exit.isSuccess(exit) && exit.value === response) { + return Effect.succeed({ + shown: "", + bytes: undefined, + }); + } + // Reading may wait for a body the call stopped downloading, and the hook + // can't be interrupted. + if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { + return Effect.succeed({ + shown: "", + bytes: undefined, + }); + } + const mediaType = mediaTypeOf(response.headers["content-type"]); + if ( + (mediaType !== "" && !isText(mediaType)) || + typeof TextDecoder === "undefined" + ) { + return Effect.succeed({ + shown: describeBinary(mediaType, contentLengthOf(response)), + bytes: undefined, + }); + } + const length = contentLengthOf(response); + if (length !== undefined && length > parseLimit(max)) { + return Effect.succeed({ + shown: describeBinary(mediaType, length), + bytes: undefined, + }); + } + return response.arrayBuffer.pipe( + Effect.map((buffer): ResponseBody => { + if (buffer.byteLength > parseLimit(max)) { + return { + shown: describeBinary(mediaType, buffer.byteLength), + bytes: buffer.byteLength, + }; + } + const text = new TextDecoder().decode(buffer); + return { + shown: + text === "" ? undefined : showText(text, mediaType, max, redact), + bytes: buffer.byteLength, + }; + }), + Effect.catchCause(() => + Effect.succeed({ + shown: "", + bytes: undefined, + }), + ), + ); +}; + +// --- Errors ----------------------------------------------------------------- + +/** What redaction applies to one call. */ +interface Redaction { + readonly headers: Matcher; + readonly query: Matcher; + readonly body: Matcher; +} + +/** + * A failure as it's printed. With redaction on, a plain copy of its fields + * (with its tag and message), redacted like a body: errors can carry body + * data. An `HttpError`'s `text` and a `DecodeError`'s `issue` are left + * out, as the response body line shows that data, redacted. + */ +const showFailure = ( + error: unknown, + redaction: Redaction | undefined, +): unknown => { + if (redaction === undefined || !Predicate.isObject(error)) { + return error; + } + const name = nameOf(error); + const left = + name === "HttpError" + ? "text" + : name === "DecodeError" + ? "issue" + : undefined; + const fields: Array = []; + for (const [key, value] of Object.entries(error)) { + if (key === left || key === "_tag") { + continue; + } + fields.push([ + key, + redaction.body(key) + ? redactedText + : Headers.isHeaders(value) + ? headersObject(value, redaction.headers) + : value instanceof Error + ? value + : redactValue(value, redaction.body), + ]); + } + return Object.fromEntries([ + ...(name === undefined ? [] : [["_tag", name] as const]), + ["message", messageOf(error)], + ...fields, + ]); +}; + +// --- Printing --------------------------------------------------------------- + +const labelWidth = "response headers".length; + +const detailLine = ( + label: string, + value: unknown, + color: Config["color"], +): Array => [ + ...render([segment(label.padEnd(labelWidth), "dim")], color), + value, +]; + +const summaryOf = ( + route: string, + response: HttpClientResponse.HttpClientResponse | undefined, + outcome: Outcome, + durationMs: number, + bytes: number | undefined, + slow: boolean, + method: string, +): Array => { + const parts: Array = []; + const gap = segment(" "); + if (outcome._tag === "Failure") { + parts.push(segment("✗ ", "red")); + } else if (outcome._tag === "Interrupted") { + parts.push(segment("⊘ ", "dim")); + } else if (slow) { + parts.push(segment("⚠ ", "yellow")); + } + parts.push(segment(route, "bold")); + if (response !== undefined) { + const text = statusTexts[response.status]; + parts.push( + gap, + segment( + text === undefined + ? `${response.status}` + : `${response.status} ${text}`, + statusTone(response.status), + ), + ); + } + parts.push(gap, segment(formatDuration(durationMs), "dim")); + if (bytes !== undefined) { + parts.push(gap, segment(formatBytes(bytes), "dim")); + } + if (outcome._tag === "Failure") { + const description = describeError(outcome.error, method); + parts.push( + gap, + segment( + outcome.defect ? `→ defect: ${description}` : `→ ${description}`, + "red", + ), + ); + } else if (outcome._tag === "Interrupted") { + parts.push(gap, segment("interrupted", "dim")); + } + if (slow) { + parts.push(gap, segment("(slow)", "yellow")); + } + return parts; +}; + +const logCall = ( + exit: Exit.Exit, + ctx: PluginExitContext, + config: Config, +): Effect.Effect => + Effect.gen(function* () { + const settings = ctx.options; + const outcome = outcomeOf(exit); + if (settings.only === "errors" && outcome._tag !== "Failure") { + return; + } + const { response, route, durationMs } = ctx; + const request = response?.request ?? ctx.request; + if ( + config.filter !== undefined && + !config.filter({ route, request, response, exit, durationMs }) + ) { + return; + } + const redaction: Redaction | undefined = + config.redact === undefined + ? undefined + : { + headers: matcher([ + ...(yield* Effect.service(Headers.CurrentRedactedNames)), + ...config.redact.headers, + ]), + query: config.redact.query, + body: config.redact.body, + }; + const bodyRedact = redaction?.body ?? noMatch; + const headerRedact = redaction?.headers ?? noMatch; + const body = + settings.detail === "full" && response !== undefined + ? yield* readResponseBody( + response, + exit, + settings.maxBodyLength, + bodyRedact, + ) + : undefined; + const slow = + settings.slow !== false && + Option.match(Duration.fromInput(settings.slow), { + // An invalid per-call `slow` flags nothing. + onNone: () => false, + onSome: (threshold) => durationMs >= Duration.toMillis(threshold), + }); + const summary = render( + summaryOf( + route, + response, + outcome, + durationMs, + response === undefined + ? undefined + : (contentLengthOf(response) ?? body?.bytes), + slow, + request.method, + ), + config.color, + ); + const lines: Array> = []; + if (settings.detail !== "summary") { + lines.push( + render( + [ + segment(request.method, "bold"), + segment(" "), + segment(fullUrl(request, redaction?.query ?? noMatch)), + ], + config.color, + ), + detailLine( + "request headers", + headersObject(request.headers, headerRedact), + config.color, + ), + ); + if (settings.detail === "full") { + const shown = showRequestBody( + request.body, + settings.maxBodyLength, + bodyRedact, + ); + if (shown !== undefined) { + lines.push(detailLine("request body", shown, config.color)); + } + } + if (response !== undefined) { + lines.push( + detailLine( + "response headers", + headersObject(response.headers, headerRedact), + config.color, + ), + ); + } + if (body?.shown !== undefined) { + lines.push(detailLine("response body", body.shown, config.color)); + } + if (outcome._tag === "Failure") { + lines.push( + outcome.defect + ? detailLine("defect", outcome.error, config.color) + : detailLine( + "error", + showFailure(outcome.error, redaction), + config.color, + ), + ); + } + } + yield* Console.consoleWith((console) => + Effect.sync(() => { + if (lines.length === 0) { + console.log(...summary); + return; + } + if (settings.collapsed) { + console.groupCollapsed(...summary); + } else { + console.group(...summary); + } + try { + for (const line of lines) { + console.log(...line); + } + } finally { + console.groupEnd(); + } + }), + ); + }); + +/** + * `true` in React Native, which sets `navigator.product` to + * `"ReactNative"`. Browsers, Node, Bun and Deno don't. + */ +const isReactNative = (): boolean => { + const navigator: unknown = Reflect.get(globalThis, "navigator"); + return ( + Predicate.hasProperty(navigator, "product") && + navigator.product === "ReactNative" + ); +}; + +const resolveColor = (color: ConsoleLoggerColor): Config["color"] => + color === "auto" ? ("document" in globalThis ? "css" : false) : color; + +/** + * Prints each call to the console while you develop: one line per call, + * opening a group with its URL, headers, bodies and error. + * + * ```text + * ▸ GET /todos/:id 200 OK 35 ms 1.2 kB + * ▸ ✗ POST /users/:id/roles 400 Bad Request 120 ms → InvalidRole: No such role + * ▸ ✗ GET /todos 3.0 s → NetworkError: Failed to fetch + * ▸ ⚠ GET /reports/:id 200 OK 2.4 s (slow) + * + * ▾ ✗ POST /users/:id/roles 400 Bad Request 120 ms → InvalidRole: No such role + * POST https://api.example.com/users/7/roles + * request headers { accept: "application/json", authorization: "" } + * request body { role: "owner" } + * response headers { "content-type": "application/json" } + * response body { code: "INVALID_ROLE", message: "No such role" } + * error InvalidRole { code: "INVALID_ROLE", message: "No such role" } + * ``` + * + * It logs each call once it's over, with its final outcome: the error a + * call fails with after `statusErrors` and decoding, or `interrupted`. + * Values are logged as objects, for devtools to expand. Everything goes + * through Effect's `Console` with `console.log` and groups, never + * `console.error`, which React Native turns into an error screen. + * + * What's redacted, shown as `` (names match ignoring case, `-` + * and `_`): + * + * - headers: Effect's `Headers.CurrentRedactedNames` (`authorization`, + * `cookie`, `set-cookie`, `x-api-key`), `proxy-authorization`, + * `x-auth-token`, `x-access-token`, `x-refresh-token`, + * `x-session-token`, `x-csrf-token`, `x-xsrf-token`, `x-client-secret` + * and `x-amz-security-token`; + * - JSON and form body keys, at any depth: `token`, `accessToken`, + * `refreshToken`, `idToken`, `authToken`, `apiToken`, `sessionToken`, + * `csrfToken`, `xsrfToken`, `secret`, `clientSecret`, `apiSecret`, + * `apiKey`, `privateKey`, and any name containing `password`; + * - query parameters: the same names, `key`, `sig`, `signature`, and the + * signed-URL ones (`X-Amz-Signature`, `X-Amz-Credential`, + * `X-Amz-Security-Token`, `X-Goog-Signature`, `X-Goog-Credential`, + * `AWSAccessKeyId`); query values passed as `Redacted`; and credentials + * in the URL (`https://user:password@…`). + * + * Exact names rather than patterns, so fields such as `total_tokens` + * stay visible. Add names with `redact`, or turn redaction off with + * `redact: false`. Plain-text bodies are printed as they are. + * + * A call's error is printed as a copy of its fields, redacted like a body, + * as errors can carry body data. An `HttpError`'s `text` and a + * `DecodeError`'s `issue` are left out: the response body line shows them. + * Defects are printed as they are, with their stack. + * + * A response body is read only when it's text (JSON, `text/*` other than + * event streams, XML or a form) of at most 1 MB (or 4 × `maxBodyLength`), + * and never when the call returns the response itself, which its caller + * may stream, or was interrupted. Effect caches the body, so reading it + * doesn't affect decoding. + * + * It runs from the `onExit` hook, so it sees the whole call wherever it + * sits in the plugin list: retries are one entry, and `duration` includes + * them. When a plugin fails on a response (such as a 401), that response + * is shown. Without a response, the request shown is the one the call + * built, before plugins (such as `bearer`) changed it. + * + * Throws if `slow` isn't a duration. A call's own `slow` that isn't one + * flags nothing. + * + * @example + * ```ts + * import * as Fetch from "@monarcode/effetch"; + * import { consoleLogger } from "@monarcode/effetch/plugins"; + * + * declare const isDevelopment: boolean; + * + * const makeApi = Fetch.make({ + * baseUrl: "https://api.example.com", + * plugins: [ + * consoleLogger({ + * enabled: isDevelopment, + * slow: "1 second", + * redact: { body: ["otp"] }, + * }), + * ], + * }); + * + * // Per call: + * // api.get("/export", { plugins: { consoleLogger: { detail: "summary" } } }) + * // api.get("/health", { plugins: { consoleLogger: false } }) + * ``` + * + * @since 0.1.0 + */ +export const consoleLogger = ( + options: ConsoleLoggerOptions = {}, +): ConsoleLoggerPlugin => { + const defaults: ConsoleLoggerSettings = { + detail: options.detail ?? "full", + only: options.only ?? "all", + // Metro's terminal drops collapsed groups and what's inside them. + collapsed: options.collapsed ?? !isReactNative(), + slow: options.slow ?? false, + maxBodyLength: options.maxBodyLength ?? 10_000, + }; + if ( + defaults.slow !== false && + Option.isNone(Duration.fromInput(defaults.slow)) + ) { + throw new Error( + `consoleLogger: slow must be a duration or false, got ${String(defaults.slow)}`, + ); + } + if (options.enabled === false) { + return plugin({ name: "consoleLogger", defaults }); + } + const redact = options.redact; + const config: Config = { + color: resolveColor(options.color ?? "auto"), + redact: + redact === false + ? undefined + : { + headers: [...defaultHeaders, ...(redact?.headers ?? [])], + query: matcher([...defaultQuery, ...(redact?.query ?? [])]), + body: matcher([...defaultBody, ...(redact?.body ?? [])]), + }, + filter: options.filter, + }; + return plugin({ + name: "consoleLogger", + defaults, + onExit: (exit, ctx) => logCall(exit, ctx, config), + }); +}; diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 38399e8..f3dcc05 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -5,6 +5,16 @@ export type { TokenSource, TokenValue, } from "./bearer.js"; +export { consoleLogger } from "./consoleLogger.js"; +export type { + ConsoleLoggerCall, + ConsoleLoggerColor, + ConsoleLoggerDetail, + ConsoleLoggerOptions, + ConsoleLoggerPlugin, + ConsoleLoggerRedact, + ConsoleLoggerSettings, +} from "./consoleLogger.js"; export { logger } from "./logger.js"; export type { LoggerOptions, LoggerPlugin } from "./logger.js"; export { retry } from "./retry.js"; diff --git a/test/exports.test.ts b/test/exports.test.ts index c65ee5f..f4e1bfe 100644 --- a/test/exports.test.ts +++ b/test/exports.test.ts @@ -20,6 +20,7 @@ describe("public entry points", () => { expect(Object.keys(Plugins).sort()).toEqual([ "TimeoutError", "bearer", + "consoleLogger", "logger", "retry", "timeout", diff --git a/test/plugins.test.ts b/test/plugins.test.ts index c5c35b5..3a4a8c1 100644 --- a/test/plugins.test.ts +++ b/test/plugins.test.ts @@ -7,15 +7,18 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import { describe, expect } from "vitest"; import * as Fetch from "../src/index.js"; import { CurrentOverrides, CurrentRoute } from "../src/internal/plugins.js"; +import { retry } from "../src/plugins/index.js"; import { base, setup, wire } from "./utils/client.js"; +import { after } from "./utils/clock.js"; import { captureLogs } from "./utils/logs.js"; -import { failure, hang, type Script } from "./utils/mockClient.js"; +import { delayed, failure, hang, type Script } from "./utils/mockClient.js"; import { reply, respond } from "./utils/reply.js"; const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); @@ -252,7 +255,7 @@ describe("wrap", () => { }); describe("per-call overrides", () => { - it.effect("false skips the plugin's hooks, wrap and onError", () => + it.effect("false skips the plugin's hooks, wrap, onError and onExit", () => Effect.gen(function* () { const log: Array = []; const skippable = Fetch.plugin({ @@ -268,6 +271,7 @@ describe("per-call overrides", () => { return response; }), onError: () => Effect.sync(() => log.push("onError")), + onExit: () => Effect.sync(() => log.push("onExit")), wrap: (client) => HttpClient.transformResponse( client, @@ -283,7 +287,13 @@ describe("per-call overrides", () => { expect(log).toEqual([]); expect(wire(mock, 0).headers["x-trail"]).toBe("other"); yield* Effect.flip(api.get("/todos")); - expect(log).toEqual(["onRequest", "onResponse", "wrap", "onError"]); + expect(log).toEqual([ + "onRequest", + "onResponse", + "wrap", + "onError", + "onExit", + ]); }), ); @@ -516,6 +526,362 @@ describe("onError", () => { ); }); +/** What one `onExit` call saw. */ +interface ExitSeen { + readonly exit: Exit.Exit; + readonly ctx: Fetch.PluginExitContext; +} + +const exitObserver = (seen: Array) => + Fetch.plugin({ + name: "exits", + onExit: (exit, ctx) => Effect.sync(() => seen.push({ exit, ctx })), + }); + +const only = (seen: ReadonlyArray): ExitSeen => { + expect(seen).toHaveLength(1); + return seen[0] as ExitSeen; +}; + +describe("onExit", () => { + it.effect("sees a success, with the decoded output and the response", () => + Effect.gen(function* () { + const seen: Array = []; + const { api } = yield* setup([reply.json({ id: 1, title: "a" })], { + plugins: [exitObserver(seen)], + }); + const todo = yield* api.get("/todos/:id", { + params: { id: 1 }, + output: Todo, + }); + const { exit, ctx } = only(seen); + expect(exit).toEqual(Exit.succeed(todo)); + expect(ctx.route).toBe("GET /todos/:id"); + expect(ctx.request.url).toBe(`${base}/todos/1`); + expect(ctx.response?.status).toBe(200); + expect(ctx.durationMs).toBe(0); + expect(ctx.options).toBeUndefined(); + }), + ); + + it.effect( + "sees the response itself as the result of a call without output", + () => + Effect.gen(function* () { + const seen: Array = []; + const { api } = yield* setup([reply.text("hi")], { + plugins: [exitObserver(seen)], + }); + const response = yield* api.get("/hello"); + const { exit, ctx } = only(seen); + expect(Exit.isSuccess(exit) && exit.value).toBe(response); + expect(ctx.response).toBe(response); + }), + ); + + it.effect.each([ + ["HttpError", [reply.json({}, { status: 500 })], 500], + ["DecodeError", [reply.json({ id: "1" })], 200], + ["NetworkError", [failure()], undefined], + ] as const)("sees a %s, with the response if any", ([tag, script, status]) => + Effect.gen(function* () { + const seen: Array = []; + const { api } = yield* setup(script, { + plugins: [exitObserver(seen)], + }); + const error = yield* Effect.flip(api.get("/todos", { output: Todo })); + expect(error).toMatchObject({ _tag: tag }); + const { exit, ctx } = only(seen); + expect(exit).toEqual(Exit.fail(error)); + expect(ctx.response?.status).toBe(status); + }), + ); + + it.effect("sees a statusErrors error", () => + Effect.gen(function* () { + class Gone extends Schema.TaggedError()("Gone", { + message: Schema.String, + }) {} + const seen: Array = []; + const { api } = yield* setup( + [reply.json({ message: "gone" }, { status: 410 })], + { plugins: [exitObserver(seen)], statusErrors: { 410: Gone } }, + ); + const error = yield* Effect.flip(api.get("/todos")); + expect(error).toBeInstanceOf(Gone); + expect(only(seen).exit).toEqual(Exit.fail(error)); + }), + ); + + it.effect("sees an interruption, which it can't stop", () => + Effect.gen(function* () { + const seen: Array = []; + const { api } = yield* setup([hang], { + plugins: [exitObserver(seen)], + }); + const fiber = yield* Effect.forkChild(api.get("/slow")); + yield* Effect.yieldNow; + yield* Fiber.interrupt(fiber); + const { exit, ctx } = only(seen); + expect( + Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause), + ).toBe(true); + expect(ctx.response).toBeUndefined(); + }), + ); + + it.effect("sees defects, which still kill the call", () => + Effect.gen(function* () { + const seen: Array = []; + const boom = new Error("boom"); + const broken = Fetch.plugin({ + name: "broken", + onResponse: () => Effect.die(boom), + }); + const { api } = yield* setup([reply.json({})], { + plugins: [exitObserver(seen), broken], + }); + const exit = yield* Effect.exit(api.get("/todos")); + expect(Exit.hasDies(exit)).toBe(true); + expect(only(seen).exit).toEqual(exit); + }), + ); + + it.effect("sees the network's response when a plugin fails after it", () => + Effect.gen(function* () { + const seen: Array = []; + const expire = Fetch.plugin({ + name: "expire", + onResponse: () => Effect.fail(new SessionExpired()), + }); + const { api } = yield* setup([reply.empty(401)], { + plugins: [exitObserver(seen), expire], + }); + const error = yield* Effect.flip(api.get("/me")); + const { exit, ctx } = only(seen); + expect(exit).toEqual(Exit.fail(error)); + expect(ctx.response?.status).toBe(401); + }), + ); + + it.effect("has no response when the last attempt got none", () => + Effect.gen(function* () { + const seen: Array = []; + const { api } = yield* setup([reply.empty(503), failure()], { + plugins: [ + retry({ times: 1, schedule: Schedule.spaced("10 millis") }), + exitObserver(seen), + ], + }); + yield* Effect.flip(after(api.get("/todos"), "10 millis")); + expect(only(seen).ctx.response).toBeUndefined(); + }), + ); + + it.effect("isn't given the response of a call made inside it", () => + Effect.gen(function* () { + const seen: Array = []; + const inner: { calls?: Effect.Effect | undefined } = + {}; + // After the outer response, makes calls of its own, then fails. + const nested = Fetch.plugin({ + name: "nested", + onResponse: (response) => + Effect.gen(function* () { + if (!response.request.url.endsWith("/outer")) { + return response; + } + const calls = inner.calls; + inner.calls = undefined; + if (calls !== undefined) { + yield* Effect.orDie(calls); + } + return yield* new SessionExpired(); + }), + }); + const script = (request: { readonly url: string }) => + request.url.endsWith("/outer") ? reply.empty(401) : reply.json({}); + const { api } = yield* setup(script, { + plugins: [exitObserver(seen), nested], + }); + const { api: other } = yield* setup(script, { + plugins: [exitObserver(seen)], + }); + const expectOuter401 = Effect.gen(function* () { + seen.length = 0; + yield* Effect.flip(api.get("/outer")); + expect( + seen.map(({ ctx }) => [ctx.route, ctx.response?.status]), + ).toEqual([ + ["GET /inner", 200], + ["GET /outer", 401], + ]); + }); + const raw = HttpClientRequest.get("/raw"); + inner.calls = Effect.andThen( + api.get("/inner"), + api.httpClient.execute(raw), + ); + yield* expectOuter401; + inner.calls = Effect.andThen( + other.get("/inner"), + other.httpClient.execute(raw), + ); + yield* expectOuter401; + }), + ); + + it.effect( + "runs in array order after the onError hooks, with the options", + () => + Effect.gen(function* () { + const log: Array = []; + const hooks = (name: Name) => + Fetch.plugin({ + name, + defaults: { level: "warn" }, + onError: () => Effect.sync(() => log.push(`${name} onError`)), + onExit: (_exit, ctx) => + Effect.sync(() => + log.push(`${name} onExit ${ctx.options.level}`), + ), + }); + const { api } = yield* setup([reply.empty(500)], { + plugins: [hooks("first"), hooks("second")], + }); + yield* Effect.flip( + api.get("/todos", { plugins: { second: { level: "error" } } }), + ); + expect(log).toEqual([ + "first onError", + "second onError", + "first onExit warn", + "second onExit error", + ]); + }), + ); + + it.effect( + "sees the built request, and the sent one as the response's request", + () => + Effect.gen(function* () { + const seen: Array = []; + const { api } = yield* setup([reply.json({})], { + plugins: [exitObserver(seen), tracer("tag", [])], + }); + yield* api.get("/todos"); + const { ctx } = only(seen); + expect(ctx.request.headers["x-trail"]).toBeUndefined(); + expect(ctx.response?.request.headers["x-trail"]).toBe("tag"); + }), + ); + + it.effect( + "measures the call until it's decoded, retries included, onError hooks not", + () => + Effect.gen(function* () { + const seen: Array = []; + const slowReport = Fetch.plugin({ + name: "slowReport", + onError: () => Effect.sleep("1 second"), + }); + const { api } = yield* setup( + [ + delayed("100 millis", reply.empty(503)), + delayed("100 millis", reply.json({})), + ], + { + // Last in the list: inside the retries, and still sees them all. + plugins: [ + retry({ + times: 1, + schedule: Schedule.spaced("50 millis"), + }), + slowReport, + exitObserver(seen), + ], + }, + ); + yield* Effect.flip( + after(api.get("/todos", { output: Todo }), "2 seconds"), + ); + const { exit, ctx } = only(seen); + expect(Exit.isFailure(exit)).toBe(true); + expect(ctx.response?.status).toBe(200); + expect(ctx.durationMs).toBe(250); + }), + ); + + it.effect("can't change the outcome when it dies, and is logged", () => + Effect.gen(function* () { + const seen: Array = []; + const logs = captureLogs(); + const dying = Fetch.plugin({ + name: "dying", + onExit: () => Effect.die(new Error("hook failed")), + }); + const { api } = yield* setup([reply.json({ id: 1, title: "a" })], { + plugins: [dying, exitObserver(seen)], + }); + const todo = yield* logs.capture( + api.get("/todos/1", { output: Todo }), + ); + expect(todo).toEqual({ id: 1, title: "a" }); + expect(seen).toHaveLength(1); + const entry = logs.entries.find((logged) => + String(logged.message[0]).includes('onExit hook of plugin "dying"'), + ); + expect(entry?.level).toBe("Debug"); + expect(entry && Cause.squash(entry.cause)).toEqual( + new Error("hook failed"), + ); + }), + ); + + it.effect("can use the services captured by make", () => + Effect.gen(function* () { + const tokens: Array = []; + const withSession = Fetch.plugin({ + name: "withSession", + onExit: () => + Effect.map(Effect.service(Session), ({ token }) => { + tokens.push(token); + }), + }); + const { api } = yield* setup([reply.json({})], { + plugins: [withSession], + }).pipe(Effect.provideService(Session, { token: "from-make" })); + yield* api.get("/todos"); + expect(tokens).toEqual(["from-make"]); + }), + ); + + it.effect("doesn't run when the call dies before the request is built", () => + Effect.gen(function* () { + const seen: Array = []; + const { api } = yield* setup([reply.json({})], { + plugins: [exitObserver(seen)], + }); + const exit = yield* Effect.exit( + api.request("GET", "/todos", { body: {} }), + ); + expect(Exit.hasDies(exit)).toBe(true); + expect(seen).toEqual([]); + }), + ); + + it.effect("doesn't run for httpClient", () => + Effect.gen(function* () { + const seen: Array = []; + const { api } = yield* setup([reply.json({})], { + plugins: [exitObserver(seen)], + }); + yield* api.httpClient.execute(HttpClientRequest.get("/todos")); + expect(seen).toEqual([]); + }), + ); +}); + describe("services", () => { const auth = Fetch.plugin({ name: "auth", @@ -582,6 +948,10 @@ const routeRecorder = (seen: Array) => Effect.sync(() => { seen.push(`onError ${ctx.route}`); }), + onExit: (_exit, ctx) => + Effect.sync(() => { + seen.push(`onExit ${ctx.route}`); + }), wrap: (client, _options, route) => HttpClient.transform(client, (response) => Effect.gen(function* () { @@ -604,6 +974,7 @@ describe("route", () => { "onRequest GET /todos/:id", "onResponse GET /todos/:id", "onError GET /todos/:id", + "onExit GET /todos/:id", ]); }), ); @@ -682,6 +1053,9 @@ describe("route", () => { "onRequest GET /outer", "onRequest undefined", ]); + expect(seen.filter((entry) => entry.startsWith("onExit"))).toEqual([ + "onExit GET /outer", + ]); }), ); }); diff --git a/test/plugins/consoleLogger.test.ts b/test/plugins/consoleLogger.test.ts new file mode 100644 index 0000000..abf725d --- /dev/null +++ b/test/plugins/consoleLogger.test.ts @@ -0,0 +1,950 @@ +import { it } from "@effect/vitest"; +import * as Console from "effect/Console"; +import type * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Headers from "effect/http/Headers"; +import * as HttpBody from "effect/http/HttpBody"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; + +import { describe, expect } from "vitest"; + +import * as Fetch from "../../src/index.js"; +import { + type ConsoleLoggerCall, + type ConsoleLoggerOptions, + type ConsoleLoggerPlugin, + bearer, + consoleLogger, + retry, +} from "../../src/plugins/index.js"; +import { base, setup } from "../utils/client.js"; +import { after } from "../utils/clock.js"; +import { delayed, failure, hang, type Script } from "../utils/mockClient.js"; +import { reply } from "../utils/reply.js"; + +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); + +class InvalidRole extends Schema.TaggedError()("InvalidRole", { + message: Schema.String, +}) {} + +/** One call to the console. */ +interface Printed { + readonly method: string; + readonly args: ReadonlyArray; +} + +/** A console that records what it's given, provided around the effect. */ +const captureConsole = () => { + const printed: Array = []; + const record = + (method: string) => + (...args: ReadonlyArray) => { + printed.push({ method, args }); + }; + const console: Console.Console = Object.assign( + Object.create(globalThis.console), + { + log: record("log"), + group: record("group"), + groupCollapsed: record("groupCollapsed"), + groupEnd: record("groupEnd"), + error: record("error"), + warn: record("warn"), + }, + ); + return { + printed, + capture: (self: Effect.Effect) => + Effect.provideService(self, Console.Console, console), + /** + * The first argument of each call's line: its group's label, or the + * line itself without a group. + */ + summaries: () => { + let depth = 0; + const lines: Array = []; + for (const entry of printed) { + if (entry.method === "groupEnd") { + depth -= 1; + } else if (depth === 0) { + lines.push(entry.args[0]); + } + if (entry.method === "group" || entry.method === "groupCollapsed") { + depth += 1; + } + } + return lines; + }, + /** The values printed under `label`, in order. */ + details: (label: string) => details(printed, label), + /** The first value printed under `label`. */ + detail: (label: string) => details(printed, label)[0], + }; +}; + +const details = ( + printed: ReadonlyArray, + label: string, +): Array => + printed + .filter( + (entry) => + entry.method === "log" && + entry.args.length === 2 && + typeof entry.args[0] === "string" && + entry.args[0].trim() === label, + ) + .map((entry) => entry.args[1]); + +/** A client with `consoleLogger` (plain text) first, then `plugins`. */ +const logged = = []>( + script: Script, + options: ConsoleLoggerOptions = {}, + plugins: Ps = [] as unknown as Ps, +) => + setup(script, { + plugins: [ + consoleLogger({ color: false, ...options }), + ...plugins, + ] as const, + statusErrors: { 400: InvalidRole }, + }); + +describe("consoleLogger", () => { + it.effect( + "prints a collapsed group per call, with the URL, headers and bodies", + () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([reply.json({ id: 1, title: "a" })]); + yield* out.capture( + api.post("/todos/:id", { + params: { id: 1 }, + query: { page: 2 }, + body: { title: "a" }, + output: Todo, + }), + ); + expect(out.printed.map((entry) => entry.method)).toEqual([ + "groupCollapsed", + "log", + "log", + "log", + "log", + "log", + "groupEnd", + ]); + expect(out.printed[0]?.args).toEqual([ + "POST /todos/:id 200 OK 0 ms 20 B", + ]); + expect(out.printed[1]?.args).toEqual([ + `POST ${base}/todos/1?page=2`, + ]); + expect(out.detail("request headers")).toMatchObject({ + accept: "application/json", + "content-type": "application/json", + "content-length": "13", + }); + expect(out.detail("request body")).toEqual({ title: "a" }); + expect(out.detail("response headers")).toEqual({ + "content-type": "application/json", + }); + expect(out.detail("response body")).toEqual({ id: 1, title: "a" }); + }), + ); + + it.effect.each([ + [ + "a statusErrors error, with its message", + [reply.json({ message: "No such role" }, { status: 400 })], + "✗ GET /todos 400 Bad Request 0 ms 26 B → InvalidRole: No such role", + ], + [ + "an HttpError, by its tag", + [reply.json({}, { status: 500 })], + "✗ GET /todos 500 Internal Server Error 0 ms 2 B → HttpError", + ], + [ + "a NetworkError, with its cause", + [failure(new TypeError("Failed to fetch"))], + "✗ GET /todos 0 ms → NetworkError: Failed to fetch", + ], + [ + "a DecodeError, with its issue", + [reply.json({ id: "1", title: "a" })], + '✗ GET /todos 200 OK 0 ms 22 B → DecodeError: Expected number at ["id"]', + ], + ] as const)("summarises %s", ([, script, line]) => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged(script); + const error = yield* out.capture( + Effect.flip(api.get("/todos", { output: Todo })), + ); + expect(out.summaries()).toEqual([line]); + expect(out.detail("error")).toMatchObject({ + _tag: error._tag, + message: error.message, + }); + }), + ); + + it.effect("prints defects and interruptions", () => + Effect.gen(function* () { + const out = captureConsole(); + const boom = new Error("boom"); + const broken = Fetch.plugin({ + name: "broken", + onResponse: () => Effect.die(boom), + }); + const { api } = yield* logged([reply.json({}), hang], {}, [broken]); + yield* out.capture(Effect.exit(api.get("/todos"))); + expect(out.detail("defect")).toBe(boom); + const fiber = yield* Effect.forkChild(out.capture(api.get("/slow"))); + yield* Effect.yieldNow; + yield* Fiber.interrupt(fiber); + expect(out.summaries()).toEqual([ + "✗ GET /todos 200 OK 0 ms 2 B → defect: Error: boom", + "⊘ GET /slow 0 ms interrupted", + ]); + }), + ); + + it.effect("redacts sensitive headers, query parameters and body keys", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged( + [ + reply.json( + { + user: { id: 1, accessToken: "a", refresh_token: "r" }, + items: [{ password: "p", name: "n" }], + }, + { + headers: { + "set-cookie": "session=1", + "x-request-id": "7", + }, + }, + ), + ], + {}, + [bearer({ token: Effect.succeed("t0ken") })], + ); + yield* out.capture( + api.post("/login", { + query: { + page: 1, + access_token: "q", + sig: "s", + secret: Redacted.make("hidden"), + }, + headers: { "x-csrf-token": "c", "x-trace": "t" }, + body: { + email: "a@b.c", + password: "p", + nested: [{ apiKey: "k" }], + }, + output: Schema.Unknown, + }), + ); + expect(out.printed[1]?.args).toEqual([ + `POST ${base}/login?page=1&access_token=&sig=&secret=`, + ]); + expect(out.detail("request headers")).toMatchObject({ + authorization: "", + "x-csrf-token": "", + "x-trace": "t", + }); + expect(out.detail("request body")).toEqual({ + email: "a@b.c", + password: "", + nested: [{ apiKey: "" }], + }); + expect(out.detail("response headers")).toEqual({ + "content-type": "application/json", + "set-cookie": "", + "x-request-id": "7", + }); + expect(out.detail("response body")).toEqual({ + user: { + id: 1, + accessToken: "", + refresh_token: "", + }, + items: [{ password: "", name: "n" }], + }); + }), + ); + + it.effect( + "redacts the names given too, ignoring case, - and _, or nothing", + () => + Effect.gen(function* () { + const out = captureConsole(); + const script = [reply.json({ one_time_code: "1", card: "c" })]; + const { api } = yield* logged(script, { + redact: { + headers: ["X-Session"], + query: ["INVITE"], + body: ["oneTimeCode", "otp", /^card/], + }, + }); + yield* out.capture( + api.post("/verify", { + query: { invite: "i" }, + headers: { "x-session": "s", authorization: "a" }, + body: { otp: "1", cardNumber: "4242" }, + output: Schema.Unknown, + }), + ); + expect(out.printed[1]?.args).toEqual([ + `POST ${base}/verify?invite=`, + ]); + expect(out.detail("request headers")).toMatchObject({ + "x-session": "", + authorization: "", + }); + expect(out.detail("request body")).toEqual({ + otp: "", + cardNumber: "", + }); + expect(out.detail("response body")).toEqual({ + one_time_code: "", + card: "", + }); + + const plain = captureConsole(); + const { api: unredacted } = yield* logged(script, { + redact: false, + }); + yield* plain.capture( + unredacted.post("/verify", { + headers: { authorization: "a" }, + body: { password: "p" }, + }), + ); + expect(plain.detail("request headers")).toMatchObject({ + authorization: "a", + }); + expect(plain.detail("request body")).toEqual({ password: "p" }); + }), + ); + + it.effect("follows Effect's Headers.CurrentRedactedNames", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([reply.json({})]); + yield* out.capture( + api + .get("/todos", { headers: { "x-internal": "i", cookie: "c" } }) + .pipe( + Effect.provideService(Headers.CurrentRedactedNames, [ + "x-internal", + ]), + ), + ); + expect(out.detail("request headers")).toMatchObject({ + "x-internal": "", + cookie: "c", + }); + }), + ); + + it.effect("prints form bodies field by field", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([reply.empty(204)]); + const form = new FormData(); + form.append("name", "ada"); + form.append("tag", "a"); + form.append("tag", "b"); + form.append("password", "p"); + form.append( + "avatar", + new File(["png!"], "avatar.png", { type: "image/png" }), + ); + yield* out.capture(api.post("/form", { body: form })); + expect(out.detail("request body")).toEqual({ + name: "ada", + tag: ["a", "b"], + password: "", + avatar: "", + }); + expect(out.detail("response body")).toBeUndefined(); + }), + ); + + it.effect( + "prints urlencoded, text, binary and streamed request bodies", + () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([reply.empty(204)], { + detail: "full", + }); + yield* out.capture( + api.post("/a", { + body: HttpBody.text( + "a=1&token=t", + "application/x-www-form-urlencoded", + ), + }), + ); + yield* out.capture(api.post("/b", { body: "hello" })); + yield* out.capture( + api.post("/c", { body: new Uint8Array([1, 2, 3]) }), + ); + const bodies = out.details("request body"); + expect(bodies).toEqual([ + { a: "1", token: "" }, + "hello", + "", + ]); + }), + ); + + it.effect("cuts long bodies, printing long JSON as its text", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged( + [reply.json({ title: "x".repeat(40) }), reply.text("y".repeat(40))], + { maxBodyLength: 20 }, + ); + yield* out.capture(api.get("/json", { output: Schema.Unknown })); + yield* out.capture( + api.get("/text", { output: Schema.Unknown }).pipe(Effect.exit), + ); + const bodies = out.details("response body"); + expect(bodies).toEqual([ + '{"title":"xxxxxxxxxx… (32 more characters)', + "yyyyyyyyyyyyyyyyyyyy… (20 more characters)", + ]); + }), + ); + + it.effect( + "reads no body the call returns as its response, or that isn't text", + () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([ + reply.text("streamed"), + reply.text("events", { + headers: { "content-type": "text/event-stream" }, + }), + reply.text("png", { + status: 500, + headers: { + "content-type": "image/png", + "content-length": "3", + }, + }), + ]); + const response = yield* out.capture(api.get("/raw")); + expect(yield* response.text).toBe("streamed"); + yield* out.capture( + api.get("/events", { output: Schema.String }).pipe(Effect.exit), + ); + yield* out.capture(Effect.exit(api.get("/image"))); + const bodies = out.details("response body"); + expect(bodies).toEqual([ + "", + "", + "", + ]); + }), + ); + + it.effect( + "shows the response a plugin failed on, with the plugin's error", + () => + Effect.gen(function* () { + class Expired extends Schema.TaggedError()( + "Expired", + {}, + ) {} + const out = captureConsole(); + const expire = Fetch.plugin({ + name: "expire", + onResponse: (response) => + response.status === 401 + ? Effect.fail(new Expired()) + : Effect.succeed(response), + }); + const { api } = yield* logged( + [reply.json({ reason: "expired" }, { status: 401 })], + {}, + [expire], + ); + yield* out.capture(Effect.flip(api.get("/me"))); + expect(out.summaries()).toEqual([ + "✗ GET /me 401 Unauthorized 0 ms 20 B → Expired", + ]); + expect(out.detail("response body")).toEqual({ reason: "expired" }); + expect(out.detail("error")).toEqual({ + _tag: "Expired", + message: "", + }); + }), + ); + + it.effect("redacts the body data errors carry", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([ + reply.json( + { code: "STALE", refreshToken: "r" }, + { status: 409, headers: { "set-cookie": "s=1" } }, + ), + reply.json({ id: "1", title: "a", accessToken: "t" }), + ]); + yield* out.capture(Effect.flip(api.get("/a"))); + yield* out.capture(Effect.flip(api.get("/b", { output: Todo }))); + const [httpError, decodeError] = out.details("error"); + expect(httpError).toEqual({ + _tag: "HttpError", + message: `GET ${base}/a: HTTP 409`, + method: "GET", + url: `${base}/a`, + status: 409, + headers: { + "content-type": "application/json", + "set-cookie": "", + }, + body: { code: "STALE", refreshToken: "" }, + }); + expect(decodeError).toMatchObject({ + _tag: "DecodeError", + status: 200, + }); + expect(decodeError).not.toHaveProperty("issue"); + expect(JSON.stringify(out.printed)).not.toMatch(/"(r|t)"/); + }), + ); + + it.effect("prints errors as they are with redact: false", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([reply.json({}, { status: 500 })], { + redact: false, + }); + const error = yield* out.capture(Effect.flip(api.get("/a"))); + expect(out.detail("error")).toBe(error); + }), + ); + + it.effect("prints one line per call with detail: summary", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([reply.json({}, { status: 404 })], { + detail: "summary", + }); + yield* out.capture( + Effect.flip(api.get("/todos/:id", { params: { id: 1 } })), + ); + expect(out.printed).toEqual([ + { + method: "log", + args: ["✗ GET /todos/:id 404 Not Found 0 ms → HttpError"], + }, + ]); + }), + ); + + it.effect("leaves the bodies out with detail: headers", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([reply.json({ id: 1 })], { + detail: "headers", + }); + yield* out.capture(api.post("/todos", { body: { title: "a" } })); + expect(out.detail("request headers")).toBeDefined(); + expect(out.detail("request body")).toBeUndefined(); + expect(out.detail("response body")).toBeUndefined(); + // The body isn't read, so only `content-length` could give a size. + expect(out.summaries()).toEqual(["POST /todos 200 OK 0 ms"]); + }), + ); + + it.effect("prints only failed calls with only: errors", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged( + [reply.json({}), reply.json({}, { status: 500 })], + { only: "errors", detail: "summary" }, + ); + yield* out.capture(api.get("/ok")); + yield* out.capture(Effect.flip(api.get("/broken"))); + expect(out.summaries()).toEqual([ + "✗ GET /broken 500 Internal Server Error 0 ms → HttpError", + ]); + }), + ); + + it.effect("opens expanded groups with collapsed: false", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([reply.json({})], { collapsed: false }); + yield* out.capture(api.get("/todos")); + expect(out.printed[0]?.method).toBe("group"); + }), + ); + + it.effect("flags slow calls, timing the whole call, retries included", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged( + [ + delayed("600 millis", reply.empty(503)), + delayed("600 millis", reply.json({})), + delayed("10 millis", reply.json({})), + ], + { slow: "1 second", detail: "summary" }, + [retry({ times: 1, schedule: Schedule.spaced("100 millis") })], + ); + yield* out.capture(after(api.get("/report"), "2 seconds")); + yield* out.capture(after(api.get("/quick"), "10 millis")); + expect(out.summaries()).toEqual([ + "⚠ GET /report 200 OK 1.3 s (slow)", + "GET /quick 200 OK 10 ms", + ]); + }), + ); + + it.effect("can be changed or skipped per call", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([reply.json({})]); + yield* out.capture( + api.get("/brief", { + plugins: { consoleLogger: { detail: "summary" } }, + }), + ); + yield* out.capture( + api.get("/health", { plugins: { consoleLogger: false } }), + ); + expect(out.printed).toEqual([ + { method: "log", args: ["GET /brief 200 OK 0 ms"] }, + ]); + }), + ); + + it.effect("prints only the calls filter accepts", () => + Effect.gen(function* () { + const out = captureConsole(); + const seen: Array = []; + const { api } = yield* logged([reply.json({})], { + detail: "summary", + filter: (call) => { + seen.push(call); + return call.route !== "GET /health"; + }, + }); + yield* out.capture(api.get("/health")); + yield* out.capture(api.get("/todos")); + expect(out.summaries()).toEqual(["GET /todos 200 OK 0 ms"]); + expect(seen.map((call) => call.request.url)).toEqual([ + `${base}/health`, + `${base}/todos`, + ]); + expect(seen[0]?.response?.status).toBe(200); + }), + ); + + it.effect("does nothing when enabled is false", () => + Effect.gen(function* () { + const out = captureConsole(); + const plugin = consoleLogger({ enabled: false }); + expect(plugin.onExit).toBeUndefined(); + const { api } = yield* setup([reply.json({})], { plugins: [plugin] }); + yield* out.capture(api.get("/todos")); + expect(out.printed).toEqual([]); + }), + ); + + it.effect("styles with %c for css, and escape codes for ansi", () => + Effect.gen(function* () { + const css = captureConsole(); + const ansi = captureConsole(); + const { api: styled } = yield* logged([reply.json({})], { + color: "css", + detail: "summary", + }); + const { api: colored } = yield* logged([reply.json({})], { + color: "ansi", + detail: "summary", + }); + yield* css.capture(styled.get("/todos")); + yield* ansi.capture(colored.get("/todos")); + expect(css.printed[0]?.args).toEqual([ + "%cGET /todos%c %c200 OK%c %c0 ms", + "font-weight: bold", + "", + "color: #2e9d4f", + "", + "color: #888", + ]); + expect(ansi.printed[0]?.args).toEqual([ + "\u001b[1mGET /todos\u001b[22m \u001b[32m200 OK\u001b[39m \u001b[2m0 ms\u001b[22m", + ]); + }), + ); + + it.effect("escapes % in printed text", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([reply.json({})], { detail: "summary" }); + yield* out.capture(api.get("/files/%d")); + expect(out.printed[0]?.args).toEqual(["GET /files/%%d 200 OK 0 ms"]); + }), + ); + + it.effect( + "uses the request plugins sent, or the built one without a response", + () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged( + [reply.json({}), failure()], + { redact: false }, + [bearer({ token: Effect.succeed("t0ken") })], + ); + yield* out.capture(api.get("/sent")); + yield* out.capture(Effect.flip(api.get("/lost"))); + const headers = out.details("request headers"); + expect(headers[0]).toMatchObject({ authorization: "Bearer t0ken" }); + expect(headers[1]).not.toHaveProperty("authorization"); + }), + ); +}); + +describe("consoleLogger details", () => { + it.effect("leaves fields that only mention tokens alone", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([ + reply.json( + { usage: { total_tokens: 3 }, token_type: "Bearer", tokens: 2 }, + { headers: { "x-ratelimit-remaining-tokens": "90" } }, + ), + ]); + yield* out.capture(api.get("/usage", { output: Schema.Unknown })); + expect(out.detail("response body")).toEqual({ + usage: { total_tokens: 3 }, + token_type: "Bearer", + tokens: 2, + }); + expect(out.detail("response headers")).toMatchObject({ + "x-ratelimit-remaining-tokens": "90", + }); + }), + ); + + it.effect("matches header names ignoring - and _ too", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([reply.json({})], { + redact: { headers: ["x_session", "clientId"] }, + }); + yield* out.capture( + api.get("/a", { headers: { "x-session": "s", "client-id": "c" } }), + ); + expect(out.detail("request headers")).toMatchObject({ + "x-session": "", + "client-id": "", + }); + }), + ); + + it.effect("redacts credentials in the URL", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* setup([reply.json({})], { + baseUrl: "https://user:pw@api.test", + plugins: [consoleLogger({ color: false })], + }); + yield* out.capture(api.get("/a")); + expect(out.printed[1]?.args).toEqual([ + "GET https://@api.test/a", + ]); + }), + ); + + it.effect("hides what's nested too deep to check", () => + Effect.gen(function* () { + const out = captureConsole(); + let deep: unknown = { password: "p4ss" }; + for (let level = 0; level < 70; level++) { + deep = { next: deep }; + } + const { api } = yield* logged([reply.json(deep)]); + yield* out.capture(api.get("/deep", { output: Schema.Unknown })); + const printed = JSON.stringify(out.detail("response body")); + expect(printed).not.toContain("p4ss"); + expect(printed).toContain("<…>"); + }), + ); + + it.effect("doesn't parse bodies too large to print", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([ + reply.json({ a: 1 }, { headers: { "content-length": "2000000" } }), + ]); + yield* out.capture( + api.get("/big", { output: Schema.Unknown }).pipe(Effect.exit), + ); + expect(out.detail("response body")).toBe(""); + }), + ); + + it.effect("ignores an empty content-length", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([ + reply.json({ a: 1 }, { headers: { "content-length": "" } }), + ]); + yield* out.capture(api.get("/a", { output: Schema.Unknown })); + expect(out.detail("response body")).toEqual({ a: 1 }); + }), + ); + + it.effect("prints no body for HEAD and 204 responses", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([ + reply.json({ a: 1 }), + reply.empty(204), + ]); + yield* out.capture(api.head("/a")); + yield* out.capture(api.del("/a")); + expect(out.details("response headers")).toHaveLength(2); + expect(out.details("response body")).toEqual([]); + }), + ); + + it.effect("doesn't read the body of an interrupted call", () => + Effect.gen(function* () { + const out = captureConsole(); + // Headers arrive, but the body never ends. + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(new ReadableStream({ start: () => {} }), { + headers: { "content-type": "application/json" }, + }), + ), + ), + ); + const api = yield* Fetch.make({ + baseUrl: base, + plugins: [consoleLogger({ color: false })], + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + const fiber = yield* Effect.forkChild( + out.capture(api.get("/stream", { output: Schema.Unknown })), + ); + yield* Effect.yieldNow; + yield* Fiber.interrupt(fiber); + expect(out.summaries()).toEqual([ + "⊘ GET /stream 200 OK 0 ms interrupted", + ]); + expect(out.detail("response body")).toBe( + "", + ); + }), + ); + + it.effect("describes Blob bodies", () => + Effect.gen(function* () { + const out = captureConsole(); + const { api } = yield* logged([reply.empty(204)]); + yield* out.capture( + api.post("/upload", { + body: new Blob(["abc"], { type: "text/plain" }), + }), + ); + expect(out.detail("request body")).toBe(""); + }), + ); + + it.effect("checks slow when it's made, and ignores a bad one per call", () => + Effect.gen(function* () { + expect(() => + consoleLogger({ slow: "soon" as unknown as Duration.Input }), + ).toThrow("consoleLogger: slow must be a duration or false, got soon"); + const out = captureConsole(); + const { api } = yield* logged([reply.json({})], { detail: "summary" }); + yield* out.capture( + api.get("/a", { + plugins: { + consoleLogger: { slow: "soon" as unknown as Duration.Input }, + }, + }), + ); + expect(out.summaries()).toEqual(["GET /a 200 OK 0 ms"]); + }), + ); + + it.effect("doesn't collapse groups by default in React Native", () => + Effect.gen(function* () { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + "navigator", + ); + Object.defineProperty(globalThis, "navigator", { + value: { product: "ReactNative" }, + configurable: true, + }); + let native: ConsoleLoggerPlugin; + let chosen: ConsoleLoggerPlugin; + try { + native = consoleLogger({ color: false }); + chosen = consoleLogger({ color: false, collapsed: true }); + } finally { + if (descriptor === undefined) { + Reflect.deleteProperty(globalThis, "navigator"); + } else { + Object.defineProperty(globalThis, "navigator", descriptor); + } + } + const elsewhere = consoleLogger({ color: false }); + const methods: Array = []; + for (const plugin of [native, chosen, elsewhere]) { + const out = captureConsole(); + const { api } = yield* setup([reply.json({})], { + plugins: [plugin], + }); + yield* out.capture(api.get("/a")); + methods.push(out.printed[0]?.method); + } + expect(methods).toEqual(["group", "groupCollapsed", "groupCollapsed"]); + }), + ); + + it.effect("uses css colors by default where there's a document", () => + Effect.gen(function* () { + const global = globalThis as { document?: unknown }; + global.document = {}; + const plugin = consoleLogger({ detail: "summary" }); + delete global.document; + const out = captureConsole(); + const { api } = yield* setup([reply.json({})], { plugins: [plugin] }); + yield* out.capture(api.get("/a")); + expect(out.printed[0]?.args[0]).toBe("%cGET /a%c %c200 OK%c %c0 ms"); + const plain = captureConsole(); + const { api: other } = yield* setup([reply.json({})], { + plugins: [consoleLogger({ detail: "summary" })], + }); + yield* plain.capture(other.get("/a")); + expect(plain.printed[0]?.args).toEqual(["GET /a 200 OK 0 ms"]); + }), + ); +}); diff --git a/test/types/builtins.test-d.ts b/test/types/builtins.test-d.ts index 577f2ae..5196ffe 100644 --- a/test/types/builtins.test-d.ts +++ b/test/types/builtins.test-d.ts @@ -12,6 +12,8 @@ import * as Fetch from "../../src/index.js"; import { bearer, type BearerPlugin, + consoleLogger, + type ConsoleLoggerPlugin, logger, type LoggerPlugin, retry, @@ -57,9 +59,15 @@ describe("bearer", () => { }); }); -describe("logger, retry and timeout", () => { +describe("logger, consoleLogger, retry and timeout", () => { it("have named plugin types", () => { expectTypeOf(logger()).toEqualTypeOf(); + expectTypeOf(consoleLogger()).toEqualTypeOf(); + expectTypeOf( + consoleLogger({ enabled: false }), + ).toEqualTypeOf(); + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); expectTypeOf(retry()).toEqualTypeOf(); expectTypeOf( timeout({ duration: "1 second" }), @@ -79,12 +87,31 @@ describe("logger, retry and timeout", () => { timeout({ duration: "soon" }); // @ts-expect-error logs at Debug or Info only logger({ level: "Warn" }); + consoleLogger({ + enabled: true, + detail: "headers", + only: "errors", + collapsed: false, + slow: "1 second", + maxBodyLength: 100, + redact: { headers: ["x-a"], query: [/^q/], body: ["otp"] }, + filter: (call) => call.route !== "GET /health", + color: "ansi", + }); + consoleLogger({ redact: false, color: false, slow: false }); + // @ts-expect-error not a detail level + consoleLogger({ detail: "everything" }); + // @ts-expect-error not a duration + consoleLogger({ slow: "soon" }); + // @ts-expect-error not a color mode + consoleLogger({ color: true }); }); }); const make = Fetch.make({ plugins: [ logger(), + consoleLogger(), retry(), timeout({ duration: "10 seconds" }), bearer({ token }), @@ -116,6 +143,7 @@ describe("make with the built-ins", () => { api.get("/todos", { plugins: { logger: false, + consoleLogger: { detail: "summary", slow: false }, retry: { times: 0 }, timeout: { duration: "1 minute" }, bearer: false, @@ -125,6 +153,9 @@ describe("make with the built-ins", () => { api.get("/todos", { plugins: { bearer: { scheme: "Token" } } }); // @ts-expect-error logger can only be skipped api.get("/todos", { plugins: { logger: { level: "Info" } } }); + api.get("/todos", { plugins: { consoleLogger: false } }); + // @ts-expect-error consoleLogger's color is set once, by make + api.get("/todos", { plugins: { consoleLogger: { color: "css" } } }); // @ts-expect-error only times can be overridden api.get("/todos", { plugins: { retry: { methods: ["POST"] } } }); // @ts-expect-error not a duration diff --git a/test/types/plugin.test-d.ts b/test/types/plugin.test-d.ts index b22eeb8..3884290 100644 --- a/test/types/plugin.test-d.ts +++ b/test/types/plugin.test-d.ts @@ -1,12 +1,19 @@ import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import type * as Exit from "effect/Exit"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import type * as HttpClientResponse from "effect/http/HttpClientResponse"; import { describe, expectTypeOf, it } from "vitest"; -import { plugin, type Plugin, type PluginContext } from "../../src/Plugin.js"; +import { + plugin, + type Plugin, + type PluginContext, + type PluginExitContext, +} from "../../src/Plugin.js"; class A extends Data.TaggedError("A")<{}> {} class B extends Data.TaggedError("B")<{}> {} @@ -114,6 +121,54 @@ describe("plugin()", () => { >(); }); + it("adds the services of onExit", () => { + const p = plugin({ + name: "p", + onError: () => Effect.asVoid(Effect.service(Clock)), + onExit: () => Effect.asVoid(Effect.service(Session)), + }); + expectTypeOf>().toEqualTypeOf< + Session | Clock + >(); + expectTypeOf>().toBeNever(); + }); + + it("gives onExit the exit, the response, the duration and the route", () => { + plugin({ + name: "exits", + defaults: { level: "warn" }, + onExit: (exit, ctx) => { + expectTypeOf(exit).toEqualTypeOf>(); + expectTypeOf(ctx).toEqualTypeOf< + PluginExitContext<{ level: string }> + >(); + expectTypeOf(ctx.route).toEqualTypeOf(); + expectTypeOf(ctx.durationMs).toEqualTypeOf(); + expectTypeOf(ctx.response).toEqualTypeOf< + HttpClientResponse.HttpClientResponse | undefined + >(); + return Effect.void; + }, + }); + }); + + it("accepts an onExit annotated with PluginContext", () => { + const p = plugin({ + name: "loose", + onExit: (_exit, ctx: PluginContext) => + Effect.asVoid(Effect.succeed(ctx.route)), + }); + expectTypeOf>().toBeNever(); + }); + + it("rejects an onExit that can fail", () => { + type Failing = ( + exit: Exit.Exit, + ctx: PluginExitContext, + ) => Effect.Effect; + expectTypeOf().not.toExtend>(); + }); + it("lets wrap add an error", () => { expectTypeOf< Plugin.Errors<[typeof timeout]> From 45827a25f65cc395c4ab3393fba78f9e1284be22 Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 9 Nov 2026 04:50:13 +0100 Subject: [PATCH 3/4] chore(release): bump version to 0.1.0-rc.2 --- CHANGELOG.md | 17 +++++++++++------ README.md | 5 +++-- package.json | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fc726..2149d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,9 +38,7 @@ All notable changes to `@monarcode/effetch`, newest first. `UnboundEndpoint`, `UnboundDefinition`, the option types, the method signatures, `CallEffect`, `CallError` and `CallServices` gain a type parameter for the maps, last and defaulting to `undefined`, so existing - type references keep compiling. Values typed as `Client`, `Endpoint` or - `UnboundEndpoint` that you build by hand, such as test doubles, need a - `statusErrors` property. + type references keep compiling. - Plugins can have an `onExit(exit, ctx)` hook. It runs once per call, when it's over, whatever the outcome: success, failure, defect or interruption. It gets the call's `Exit` (with the decoded `output`, or the response for @@ -60,9 +58,16 @@ All notable changes to `@monarcode/effetch`, newest first. headers and bodies, and the error. It redacts sensitive headers, query parameters and body keys (in errors too), reads only text bodies of at most 1 MB and never the body of a raw or interrupted call, colors with CSS - or ANSI codes, and can be limited to errors, filtered, or set per call. Its `enabled` option turns it off in - production while keeping the client's type. Groups start collapsed, - except in React Native, whose Metro terminal drops collapsed groups. + or ANSI codes, and can be limited to errors, filtered, or set per call. + Its `enabled` option turns it off in production while keeping the + client's type. Groups start collapsed, except in React Native, whose + Metro terminal drops collapsed groups. + +### Changed + +- Values typed as `Client`, `Endpoint` or `UnboundEndpoint` that you build + by hand, such as test doubles, need a `statusErrors` property: + `undefined`, or the map the real one would have. ## 0.1.0-rc.1 diff --git a/README.md b/README.md index cb71c5c..14fbb8c 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ const todo = api.get("/todos/:id", { params: { id: 1 }, output: Todo }); ``` > **Release candidate.** effetch is close to its first stable release -> (`0.1.0-rc.N`). The API is frozen; only fixes and documentation changes -> until 0.1.0. +> (`0.1.0-rc.N`). The API is settled: release candidates add small, +> opt-in features and fixes, and the [changelog](./CHANGELOG.md) lists +> anything that needs a change in your code. ## Contents diff --git a/package.json b/package.json index 943840f..5af3432 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@monarcode/effetch", - "version": "0.1.0-rc.1", + "version": "0.1.0-rc.2", "description": "Typed, schema-driven HTTP calls on Effect v4's HttpClient: path params, object queries, decoded responses and plugins", "keywords": [ "effect", From 7563d2deef316284b064970a5ddb16f6a9aa45e3 Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 9 Nov 2026 05:04:17 +0100 Subject: [PATCH 4/4] test(plugins): keep the consoleLogger Redacted query test compiling with Effect 4.0 --- test/plugins/consoleLogger.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/plugins/consoleLogger.test.ts b/test/plugins/consoleLogger.test.ts index abf725d..541d783 100644 --- a/test/plugins/consoleLogger.test.ts +++ b/test/plugins/consoleLogger.test.ts @@ -7,7 +7,6 @@ import * as Headers from "effect/http/Headers"; import * as HttpBody from "effect/http/HttpBody"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import * as Redacted from "effect/Redacted"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; @@ -25,6 +24,7 @@ import { import { base, setup } from "../utils/client.js"; import { after } from "../utils/clock.js"; import { delayed, failure, hang, type Script } from "../utils/mockClient.js"; +import { redacted } from "../utils/redacted.js"; import { reply } from "../utils/reply.js"; const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); @@ -243,7 +243,7 @@ describe("consoleLogger", () => { page: 1, access_token: "q", sig: "s", - secret: Redacted.make("hidden"), + secret: redacted("hidden"), }, headers: { "x-csrf-token": "c", "x-trace": "t" }, body: {