Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,73 @@

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<Ps, EB, SE>`, 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.
- 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.

### 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

### Added
Expand Down
295 changes: 289 additions & 6 deletions README.md

Large diffs are not rendered by default.

34 changes: 19 additions & 15 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `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. |
| [`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.
Expand All @@ -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/
Expand All @@ -60,17 +61,20 @@ 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
binds them in `data/` with `client.bind`. `wallet` makes them in `data/`
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`,
Expand Down
7 changes: 5 additions & 2 deletions examples/app/infrastructure/http/app-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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],
});

Expand Down
14 changes: 12 additions & 2 deletions examples/app/infrastructure/http/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
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,
};

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>()(
"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.
Expand Down
7 changes: 6 additions & 1 deletion examples/app/infrastructure/http/wallet-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"),
Expand Down
4 changes: 4 additions & 0 deletions examples/app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
18 changes: 16 additions & 2 deletions examples/app/modules/todos/contract/todos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>()(
"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,
Expand All @@ -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");
1 change: 1 addition & 0 deletions examples/app/modules/todos/public.ts
Original file line number Diff line number Diff line change
@@ -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";
13 changes: 13 additions & 0 deletions examples/app/modules/wallet/contract/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>()(
"TransferLimitExceeded",
{ code: Schema.Literal("LIMIT_EXCEEDED"), reason: Schema.String },
) {}

export const TransferLimitExceededBody = TransferLimitExceeded.pipe(
Schema.encodeKeys({ code: "error" }),
);
8 changes: 7 additions & 1 deletion examples/app/modules/wallet/data/wallet.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" } },
}),
}));
Expand Down
1 change: 1 addition & 0 deletions examples/app/modules/wallet/public.ts
Original file line number Diff line number Diff line change
@@ -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";
24 changes: 22 additions & 2 deletions examples/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
Expand All @@ -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 }),
Expand Down Expand Up @@ -63,14 +79,18 @@ 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" },
},
});
});

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)),
Expand Down
Loading
Loading