From 539124318fddf2e261300893ade85df96d7f67f2 Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 12 Oct 2026 23:22:51 +0100 Subject: [PATCH 1/6] Update banner image --- banner.png | Bin 54682 -> 45788 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/banner.png b/banner.png index d4e3d335ccf7e3b0bff0f8637c2bff7c23003f36..60091788fb4c754e0ca4c3ea0b310940ca92efd4 100644 GIT binary patch literal 45788 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 GHtydWokr3C literal 54682 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 HefWO>`_8$= From b12d8294959bef2687b47e27194e2788026ed713 Mon Sep 17 00:00:00 2001 From: monarcode Date: Tue, 13 Oct 2026 09:18:28 +0100 Subject: [PATCH 2/6] feat: add endpoints and expose the call route to plugins - add `client.endpoint("GET /users/:id", definition)` to save a route with its schemas, headers and plugin overrides, plus the `Endpoint*` and route types - pass the call's route to plugins as `ctx.route` and `wrap`'s third argument, and add it to `logger` annotations - add endpoint and multi-backend examples, tests, type tests and docs --- CHANGELOG.md | 32 +++ README.md | 168 ++++++++++++++- banner.png | Bin 45788 -> 45514 bytes examples/backends.ts | 155 ++++++++++++++ examples/endpoints.ts | 118 ++++++++++ scripts/editor-check.mjs | 37 +++- scripts/smoke.sh | 48 +++++ src/Client.ts | 74 ++++++- src/Endpoint.ts | 331 ++++++++++++++++++++++++++++ src/Options.ts | 9 +- src/Plugin.ts | 33 ++- src/index.ts | 12 ++ src/internal/endpoint.ts | 80 +++++++ src/internal/plugins.ts | 71 +++++- src/internal/request.ts | 6 +- src/plugins/logger.ts | 15 +- test/endpoint.test.ts | 392 ++++++++++++++++++++++++++++++++++ test/plugins.test.ts | 141 +++++++++++- test/plugins/logger.test.ts | 38 +++- test/types/endpoint.test-d.ts | 356 ++++++++++++++++++++++++++++++ test/types/plugin.test-d.ts | 22 ++ 21 files changed, 2104 insertions(+), 34 deletions(-) create mode 100644 examples/backends.ts create mode 100644 examples/endpoints.ts create mode 100644 src/Endpoint.ts create mode 100644 src/internal/endpoint.ts create mode 100644 test/endpoint.test.ts create mode 100644 test/types/endpoint.test-d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 604e15c..bd9a313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ All notable changes to `@monarcode/effetch`, newest first. +## 0.1.0-rc.1 + +### Added + +- `client.endpoint(route, definition)` saves a call: a route such as + `"GET /users/:id"` with its `output`, `error` and `input` schemas, `headers` + and plugin overrides. It returns a function that takes one call's `params`, + `query`, `body`, `headers` and `plugins`, and sends what the equivalent + method would. + - The route is type-checked (an uppercase method, one space, then a path + starting with `/` or an absolute `http(s)://` URL), and editors suggest + the methods. + - With an `input` schema, `body` is required. `GET`, `HEAD`, `OPTIONS` and + `TRACE` endpoints reject `body` and `input`. + - A call's `headers` and plugin overrides are merged over the endpoint's, + and win. Plugin options given by both are merged. + - Each endpoint has `route`, `method` and `path` properties. +- The types `Endpoint`, `EndpointMethod`, `EndpointDefinition`, + `EndpointOptions`, `EndpointBody`, `EndpointArgs`, `EndpointRoute`, + `ValidRoute`, `RouteMethod` and `RoutePath`. +- Plugins see each call's route: its method and path before the params are + filled in, such as `"GET /users/:id"`, the same for a method call and an + endpoint. Hooks get it as `ctx.route` (`PluginContext.route`), and `wrap` + as a third argument, an `Effect` like `options`. It's `undefined` for + requests sent through `client.httpClient`. +- `logger` adds the route to its log annotations, as `route`. + +### Changed + +- `CallServices` and `CallEffect` accept `undefined` for the `input` schema + `I`, as they already did for `O`. + ## 0.1.0-alpha.2 ### Changed diff --git a/README.md b/README.md index 1fb519e..0ba69cf 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ const todo = api.get("/todos/:id", { params: { id: 1 }, output: Todo }); - [Quick start](#quick-start) - [Making a client](#making-a-client) - [Calling](#calling) +- [Endpoints](#endpoints) - [Errors](#errors) - [Plugins](#plugins) - [Testing](#testing) @@ -65,7 +66,9 @@ time. You get: - responses decoded with a `Schema`, and error bodies decoded with another; - three tagged errors to `catchTag` on, plus your plugins' own; - Effect-native plugins for auth, retries, timeouts and logging, with typed - per-call overrides. + per-call overrides; +- endpoints: a route like `"GET /users/:id"` saved with its schemas, headers + and plugin overrides, then called like a function. It has no platform-specific code, and targets browsers, React Native, Node 20+, Bun and Deno. @@ -246,6 +249,115 @@ Effect's JSON codec, where `Schema.Number` also decodes the strings `"NaN"`, default headers and plugins, but no status check and no decoding. Use it for anything the methods don't cover, such as streaming a response. +## Endpoints + +`api.endpoint` saves a call you make often: its route, its schemas, and the +headers and plugin overrides every call needs. You get back a function that +takes the rest: + +```ts +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as Fetch from "@monarcode/effetch"; +import { bearer } from "@monarcode/effetch/plugins"; + +declare const readToken: () => Promise; + +const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }); +const NewUser = Schema.Struct({ name: Schema.String }); +const Credentials = Schema.Struct({ + user: Schema.String, + password: Schema.String, +}); +const Session = Schema.Struct({ token: Schema.String }); + +const make = Effect.gen(function* () { + const api = yield* Fetch.make({ + baseUrl: "https://api.example.com", + plugins: [bearer({ token: () => readToken() })], + }); + return { + getUser: api.endpoint("GET /users/:id", { output: User }), + listUsers: api.endpoint("GET /users", { output: Schema.Array(User) }), + createUser: api.endpoint("POST /users", { input: NewUser, output: User }), + login: api.endpoint("POST /auth/login", { + input: Credentials, + output: Session, + plugins: { bearer: false }, + }), + }; +}); + +export class Api extends Context.Service>()( + "Api", +) {} + +export const program = Effect.gen(function* () { + const { getUser, listUsers, createUser } = yield* Api; + const ada = yield* createUser({ body: { name: "Ada" } }); + const users = yield* listUsers({ query: { page: 2 } }); + const user = yield* getUser({ params: { id: ada.id } }); + return { users, user }; +}); +``` + +`getUser({ params: { id } })` sends exactly what +`api.get("/users/:id", { output: User, params: { id } })` does, and returns +the same Effect. + +The route is an uppercase method, one space, then a path that starts with `/` +or an absolute `http(s)://` URL. It's checked as you type: `"GTE /users"` and +`"get /users"` don't compile, and editors suggest the methods. + +| Defined once, on the endpoint | Passed with each call | +| ----------------------------- | ------------------------- | +| `output`, `error`, `input` | `params`, `query`, `body` | +| `headers`, `plugins` | `headers`, `plugins` | + +- `params` are required when the route's path has any, as with the methods. + An endpoint that needs nothing more is called with no arguments: + `listUsers()`. +- With an `input` schema, `body` is required and typed by it. `GET`, `HEAD`, + `OPTIONS` and `TRACE` endpoints take no `body` and no `input`. +- A call's headers win over the endpoint's, which win over the client's. + Unlike the client's, an endpoint's headers can set `content-type`. +- A call's plugin override wins over the endpoint's, and when both give + options they're merged. An endpoint defined with `retry: false` can still + be retried in one call with `plugins: { retry: { times: 2 } }`. +- The schemas belong to the endpoint and can't be changed per call. +- Each endpoint has `route`, `method` and `path` properties, for log labels or + cache keys. Plugins see the same route as + [`ctx.route`](#writing-a-plugin). + +Endpoints are defined where there's a client, usually in the service that +holds it, as above. To split them across files, write functions that take the +client: + +```ts +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as Fetch from "@monarcode/effetch"; + +const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }); +const NewUser = Schema.Struct({ name: Schema.String }); + +// client.ts +export const makeClient = Fetch.make({ baseUrl: "https://api.example.com" }); +export type Client = Effect.Success; + +// users.ts +export const users = (api: Client) => ({ + get: api.endpoint("GET /users/:id", { output: User }), + create: api.endpoint("POST /users", { input: NewUser, output: User }), +}); +``` + +An endpoint is a saved call, not a contract shared with a server: for that, +use Effect's `HttpApi`. + ## Errors | Error | When | Fields | @@ -323,7 +435,7 @@ export const withEffectToken = bearer({ token: Effect.succeed("token") }); | Plugin | What it does | Per call | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | | `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. Never logs headers or query strings. | `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](#writing-a-plugin) as an annotation. Never logs headers or query strings. | `false` | | `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 }` | @@ -371,18 +483,50 @@ export const program = Effect.gen(function* () { `Fetch.plugin` defines a plugin and infers its name, errors, services and options from the hooks: -| Hook | Runs | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | -| `wrap(client, options)` | Wraps the plugins after it and the network, for retries, timeouts and the like. It can add errors but can't remove them. | +| Hook | Runs | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `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.options` is the plugin's `defaults` merged with the call's override, so a plugin needs `defaults` to have options. `ctx.request` is the request: in `onResponse`, the one this plugin passed on; in `onError`, the one the call built. +`ctx.route` is the call's method and path as written, before the params are +filled in: `"GET /users/:id"`, whether the call was +`api.get("/users/:id", …)` or an [endpoint](#endpoints). It's the same for +every user ID, so it's what to group logs, metrics and error reports by. It's +`undefined` for requests sent through `client.httpClient`. A `wrap` reads it +from its third argument, an Effect like `options`: + +```ts +import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/http/HttpClient"; + +import * as Fetch from "@monarcode/effetch"; + +declare const report: (error: unknown, route: string | undefined) => void; + +export const errorReporter = Fetch.plugin({ + name: "errorReporter", + onError: (error, ctx) => Effect.sync(() => report(error, ctx.route)), +}); + +export const timing = Fetch.plugin({ + name: "timing", + wrap: (client, _options, route) => + HttpClient.transform(client, (response, request) => + Effect.gen(function* () { + const label = (yield* route) ?? request.url; + return yield* Effect.withLogSpan(response, label); + }), + ), +}); +``` + The classic example turns a `401` into a typed `SessionExpired`, after signing out: @@ -470,7 +614,7 @@ export const deadline = Fetch.plugin({ `Fetch.Plugin.Any[]` erases the plugins' types. [`examples/`](examples) has complete programs with built-in and custom -plugins. +plugins, with endpoints, and with several backends, each with its own client. ## Testing @@ -558,8 +702,7 @@ page's URL. - A path parameter is a whole segment: `/files/:name.json` has one parameter called `name.json`. -- No query schemas or optional path parameters yet, and no endpoint - definitions: each call passes its own schemas. +- No query schemas or optional path parameters yet. - Paths shouldn't contain `?` or `#`. Pass query parameters with `query`. - `timeout` and the logged duration cover the time until the response headers arrive, not downloading the body. @@ -570,6 +713,9 @@ page's URL. `@monarcode/effetch`: - `make`, `ClientConfig`, `Client`: making a client and its methods. +- `Endpoint`, `EndpointMethod`, `EndpointDefinition`, `EndpointOptions`, + `EndpointBody`, `EndpointArgs`, `EndpointRoute`, `ValidRoute`, + `RouteMethod`, `RoutePath`: [endpoints](#endpoints). - `NetworkError`, `HttpError` (and `HttpError.With`), `DecodeError`, `FetchError`, `isFetchError`: [errors](#errors). - `plugin`, `Plugin` (and the `Plugin.*` helper types), `PluginContext`: diff --git a/banner.png b/banner.png index 60091788fb4c754e0ca4c3ea0b310940ca92efd4..526b3498b3a8b1701e37b4dbbebaf8efca9fc0d5 100644 GIT binary patch literal 45514 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 GHtydWokr3C diff --git a/examples/backends.ts b/examples/backends.ts new file mode 100644 index 0000000..b04aeb1 --- /dev/null +++ b/examples/backends.ts @@ -0,0 +1,155 @@ +import * as Config from "effect/Config"; +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 Schema from "effect/Schema"; + +import * as Fetch from "@monarcode/effetch"; +import { bearer, logger, retry, timeout } from "@monarcode/effetch/plugins"; + +// Two backends, each with its own base URL, error shape and plugins: one +// client per backend, each in its own service. + +const User = Schema.Struct({ id: Schema.String, name: Schema.String }); + +const Balance = Schema.Struct({ + amount: Schema.Finite, + currency: Schema.String, +}); + +const Transfer = Schema.Struct({ to: Schema.String, amount: Schema.Finite }); + +const Receipt = Schema.Struct({ id: Schema.String }); + +const UsersError = Schema.Struct({ code: Schema.String }); + +const WalletError = Schema.Struct({ + error: Schema.String, + reason: Schema.String, +}); + +const session: { accessToken: string | undefined } = { + accessToken: undefined, +}; + +// Plugins are plain values, so both clients can share one token source. +const auth = bearer({ token: () => session.accessToken }); + +// `ctx.route` is a method and path, with no host: both backends could have a +// `GET /health`. A plugin shared across backends takes the backend's name, +// and each client gets its own instance. +const reportErrors = (backend: string) => + Fetch.plugin({ + name: "reportErrors", + onError: (error, ctx) => + Effect.logWarning(`${backend} ${ctx.route} failed`, error), + }); + +const makeUsers = Effect.gen(function* () { + const client = yield* Fetch.make({ + baseUrl: Config.String("USERS_API_URL"), + defaultError: UsersError, + plugins: [logger(), reportErrors("users"), retry(), auth], + }); + return { + // The client itself, for calls that aren't endpoints. + client, + me: client.endpoint("GET /me", { output: User }), + getUser: client.endpoint("GET /users/:id", { output: User }), + }; +}); + +export class UsersApi extends Context.Service< + UsersApi, + Effect.Success +>()("UsersApi") {} + +const makeWallet = Effect.gen(function* () { + const client = yield* Fetch.make({ + baseUrl: Config.String("WALLET_API_URL"), + defaultError: WalletError, + plugins: [ + logger(), + reportErrors("wallet"), + timeout({ duration: "10 seconds" }), + auth, + ], + }); + return { + client, + balance: client.endpoint("GET /wallets/:userId/balance", { + output: Balance, + }), + transfer: client.endpoint("POST /transfers", { + input: Transfer, + output: Receipt, + plugins: { timeout: { duration: "30 seconds" } }, + }), + }; +}); + +export class WalletApi extends Context.Service< + WalletApi, + Effect.Success +>()("WalletApi") {} + +// One HttpClient for both. +export const ApisLive = Layer.mergeAll( + Layer.effect(UsersApi)(makeUsers), + Layer.effect(WalletApi)(makeWallet), +).pipe(Layer.provide(FetchHttpClient.layer)); + +// Each backend's errors are caught next to its calls, where `HttpError.body` +// has that backend's error shape. +const currentUser = Effect.gen(function* () { + const users = yield* UsersApi; + return yield* users.me(); +}).pipe( + Effect.catchTag("HttpError", (error) => + Effect.fail(`users: ${error.body?.code ?? `HTTP ${error.status}`}`), + ), +); + +const sendHalf = (userId: string, to: string) => + Effect.gen(function* () { + const wallet = yield* WalletApi; + const { amount } = yield* wallet.balance({ params: { userId } }); + return yield* wallet.transfer({ body: { to, amount: amount / 2 } }); + }).pipe( + Effect.catchTag("HttpError", (error) => + Effect.fail(`wallet: ${error.body?.reason ?? `HTTP ${error.status}`}`), + ), + ); + +// A one-off call to the users backend, with the client rather than an +// endpoint. +const recordActivity = (userId: string, receiptId: string) => + Effect.gen(function* () { + const users = yield* UsersApi; + yield* users.client.post("/users/:id/activity", { + params: { id: userId }, + body: { type: "transfer", receipt: receiptId }, + }); + }).pipe( + Effect.catchTag("HttpError", (error) => + Effect.fail(`users: ${error.body?.code ?? `HTTP ${error.status}`}`), + ), + ); + +export const program = (to: string) => + Effect.gen(function* () { + const user = yield* currentUser; + const receipt = yield* sendHalf(user.id, to); + yield* recordActivity(user.id, receipt.id); + return receipt; + }).pipe( + Effect.catchTag("NetworkError", () => + Effect.fail("You appear to be offline"), + ), + Effect.catchTag("DecodeError", (error) => + Effect.fail(`Unexpected response: ${error.message}`), + ), + ); + +export const main = (to: string) => program(to).pipe(Effect.provide(ApisLive)); diff --git a/examples/endpoints.ts b/examples/endpoints.ts new file mode 100644 index 0000000..5d35882 --- /dev/null +++ b/examples/endpoints.ts @@ -0,0 +1,118 @@ +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 Schema from "effect/Schema"; + +import * as Fetch from "@monarcode/effetch"; +import { bearer, logger, retry, timeout } from "@monarcode/effetch/plugins"; + +const Todo = Schema.Struct({ + id: Schema.Finite, + title: Schema.String, + done: Schema.Boolean, + due: Schema.Date, +}); + +const NewTodo = Schema.Struct({ title: Schema.String, due: Schema.Date }); + +const Credentials = Schema.Struct({ + email: Schema.String, + password: Schema.String, +}); + +const Tokens = Schema.Struct({ accessToken: Schema.String }); + +const ApiError = Schema.Struct({ code: Schema.String, message: Schema.String }); + +const session: { accessToken: string | undefined } = { + accessToken: undefined, +}; + +// The endpoints are defined once, with the client, and the service exposes +// them as its methods. +const make = Effect.gen(function* () { + const api = yield* Fetch.make({ + baseUrl: "https://api.example.com", + defaultError: ApiError, + plugins: [ + logger({ level: "Info" }), + retry(), + timeout({ duration: "10 seconds" }), + bearer({ token: () => session.accessToken }), + ], + }); + return { + login: api.endpoint("POST /auth/login", { + input: Credentials, + output: Tokens, + plugins: { bearer: false }, + }), + listTodos: api.endpoint("GET /todos", { output: Schema.Array(Todo) }), + getTodo: api.endpoint("GET /todos/:id", { output: Todo }), + createTodo: api.endpoint("POST /todos", { input: NewTodo, output: Todo }), + completeTodo: api.endpoint("PATCH /todos/:id", { + output: Todo, + headers: { "content-type": "application/merge-patch+json" }, + }), + deleteTodo: api.endpoint("DELETE /todos/:id"), + exportTodos: api.endpoint("GET /todos/export", { + output: Schema.Array(Todo), + plugins: { timeout: { duration: "2 minutes" }, retry: false }, + }), + }; +}); + +export class Api extends Context.Service>()( + "Api", +) {} + +export const ApiLive = Layer.effect(Api)(make).pipe( + Layer.provide(FetchHttpClient.layer), +); + +export const signIn = (email: string, password: string) => + Effect.gen(function* () { + const { login } = yield* Api; + const { accessToken } = yield* login({ body: { email, password } }); + session.accessToken = accessToken; + }); + +export const program = (email: string, password: string) => + Effect.gen(function* () { + yield* signIn(email, password); + const api = yield* Api; + + const created = yield* api.createTodo({ + body: { title: "Write docs", due: new Date() }, + }); + yield* api.completeTodo({ + params: { id: created.id }, + body: { done: true }, + }); + const open = yield* api.listTodos({ query: { done: false, page: 1 } }); + + // A call can override what the endpoint set: this export is retried. + const all = yield* api.exportTodos({ + plugins: { retry: { times: 2 } }, + }); + + yield* api.deleteTodo({ params: { id: created.id } }); + return { open: open.length, all: all.length }; + }).pipe( + Effect.catchTag("TimeoutError", (error) => + Effect.fail(`${error.url} didn't answer within ${error.duration}`), + ), + Effect.catchTag("HttpError", (error) => + Effect.fail(error.body?.message ?? `HTTP ${error.status}`), + ), + Effect.catchTag("NetworkError", () => + Effect.fail("You appear to be offline"), + ), + Effect.catchTag("DecodeError", (error) => + Effect.fail(`Unexpected response: ${error.message}`), + ), + ); + +export const main = (email: string, password: string) => + program(email, password).pipe(Effect.provide(ApiLive)); diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index b4f3903..cdb23fe 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -23,6 +23,10 @@ interface ListUsersParams { declare const listUsersParams: ListUsersParams; export const list = api./*query*/get("/users", { query: listUsersParams }); + +export const route = api.endpoint(""); +export const /*getUser*/getUser = api./*endpoint*/endpoint("GET /users/:id"); +export const user = getUser({ params: { /*endpointParams*/ } }); `; const config = ts.getParsedCommandLineOfConfigFile( @@ -77,6 +81,7 @@ for (const [marker, file, docs] of [ ["make", "Client.d.ts", "Makes a"], ["get", "Client.d.ts", "Sends a `GET` request"], ["bearer", path.join("plugins", "bearer.d.ts"), "Sends `Authorization"], + ["endpoint", "Client.d.ts", "Defines an endpoint"], ]) { const definitions = service.getDefinitionAtPosition(probe, at(marker)) ?? []; const targets = definitions.map((definition) => @@ -94,7 +99,9 @@ for (const [marker, file, docs] of [ `Hover on ${marker} doesn't show its docs: ${JSON.stringify(documentation.slice(0, 80))}`, ); expect( - marker === "get" || (tags.includes("example") && tags.includes("since")), + marker === "get" || + marker === "endpoint" || + (tags.includes("example") && tags.includes("since")), `Hover on ${marker} lacks @example or @since: ${tags.join(", ")}`, ); } @@ -110,6 +117,34 @@ expect( `params on "/users/:id" doesn't complete id: ${names.slice(0, 10).join(", ")}`, ); +const routeCompletions = service.getCompletionsAtPosition( + probe, + source.indexOf('endpoint("")') + 'endpoint("'.length, + undefined, +); +const routes = (routeCompletions?.entries ?? []).map((entry) => entry.name); +expect( + routes.includes("GET /") && routes.includes("POST /"), + `endpoint("") doesn't suggest the methods: ${routes.slice(0, 10).join(", ")}`, +); + +const endpointParams = ( + service.getCompletionsAtPosition(probe, at("endpointParams"), undefined) + ?.entries ?? [] +).map((entry) => entry.name); +expect( + endpointParams.includes("id"), + `params of a "GET /users/:id" endpoint don't complete id: ${endpointParams.slice(0, 10).join(", ")}`, +); + +const endpointHover = ts.displayPartsToString( + service.getQuickInfoAtPosition(probe, at("getUser"))?.displayParts, +); +expect( + endpointHover.includes('Endpoint<"GET /users/:id"'), + `Hover on an endpoint doesn't name its type and route: ${endpointHover}`, +); + const queryStart = source.lastIndexOf("\n", at("query")) + 1; const queryEnd = source.indexOf("\n", at("query")); const queryErrors = service diff --git a/scripts/smoke.sh b/scripts/smoke.sh index d1d4d60..b10ad32 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -177,9 +177,57 @@ const rejected = ( api.get("/todos", { plugins: { requestID: false } }); // @ts-expect-error bearer can only be skipped api.get("/todos", { plugins: { bearer: { scheme: "Token" } } }); + // @ts-expect-error the method is misspelled + api.endpoint("GTE /todos"); + // @ts-expect-error the :id path param is required + api.endpoint("GET /todos/:id")({}); + // @ts-expect-error GET endpoints take no input + api.endpoint("GET /todos", { input: Todo }); + // @ts-expect-error the body is required with an input + api.endpoint("POST /todos", { input: Todo })(); }; void rejected; +const routes: Array = []; +const routeSeen = Fetch.plugin({ + name: "routeSeen", + onRequest: (request, ctx) => + Effect.sync(() => { + routes.push(ctx.route); + return request; + }), +}); + +const endpointProgram = Effect.gen(function* () { + const api = yield* Fetch.make({ + baseUrl: "https://api.test", + plugins: [requestId, routeSeen], + }); + const getTodo = api.endpoint("GET /todos/:id", { + output: Todo, + plugins: { requestId: { id: "endpoint" } }, + }); + if ( + getTodo.route !== "GET /todos/:id" || + getTodo.method !== "GET" || + getTodo.path !== "/todos/:id" + ) { + throw new Error("Unexpected endpoint route, method or path"); + } + return yield* getTodo({ params: { id: 2 } }); +}).pipe(Effect.provideService(HttpClient.HttpClient, mock)); + +const viaEndpoint: { readonly id: number } = await Effect.runPromise( + endpointProgram, +); +if ( + viaEndpoint.id !== 1 || + seen.at(-1) !== "GET https://api.test/todos/2 endpoint undefined" || + routes.join() !== "GET /todos/:id" +) { + throw new Error(`Unexpected endpoint request: ${seen.at(-1)}`); +} + console.log("smoke: ok"); TS diff --git a/src/Client.ts b/src/Client.ts index 2dc7f37..060e488 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -4,9 +4,15 @@ import * as HttpClient from "effect/http/HttpClient"; import type * as HttpMethod from "effect/http/HttpMethod"; import type * as Schema from "effect/Schema"; +import type { EndpointMethod } from "./Endpoint.js"; import type { NetworkError } from "./Errors.js"; +import { makeEndpoint, type RuntimeDefinition } from "./internal/endpoint.js"; import { makeApplyDefaults, mapClientErrors } from "./internal/pipeline.js"; -import { applyPlugins, provideOverrides } from "./internal/plugins.js"; +import { + applyPlugins, + provideOverrides, + provideRoute, +} from "./internal/plugins.js"; import { makeCall, type RuntimeOptions } from "./internal/request.js"; import { hasQueryOrFragment } from "./internal/url.js"; import type { @@ -297,6 +303,66 @@ export interface Client< * ``` */ readonly del: BodyMethod; + + /** + * Defines an endpoint: a route like `"GET /users/:id"` and the schemas, + * headers and plugin overrides to call it with. You get back a function + * that takes one call's `params`, `query`, `body`, `headers` and + * `plugins`, and returns the same Effect as the equivalent method: + * `getUser({ params: { id } })` sends what + * `api.get("/users/:id", { output: User, params: { id } })` does. + * + * - The route is checked as you type: an uppercase method, one space, + * then a path starting with `/` or an absolute `http(s)://` URL. + * - `params` are required, with exactly the path's names, when the path + * has any. + * - With an `input` schema, `body` is required and typed by it. `GET`, + * `HEAD`, `OPTIONS` and `TRACE` endpoints take no body. + * - A call's `headers` and `plugins` are merged over the endpoint's, and + * win. The schemas can't be changed per call. + * + * Defining an endpoint does nothing on its own: each call sends a + * request. A route that isn't valid (which the types prevent) throws. + * + * @example + * ```ts + * import * as Context from "effect/Context"; + * import * as Effect from "effect/Effect"; + * import * as Schema from "effect/Schema"; + * import * as Fetch from "@monarcode/effetch"; + * import { bearer } from "@monarcode/effetch/plugins"; + * + * const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }); + * const NewUser = Schema.Struct({ name: Schema.String }); + * const Credentials = Schema.Struct({ user: Schema.String, password: Schema.String }); + * const Session = Schema.Struct({ token: Schema.String }); + * + * const make = Effect.gen(function* () { + * const api = yield* Fetch.make({ + * baseUrl: "https://api.example.com", + * plugins: [bearer({ token: "token" })], + * }); + * return { + * getUser: api.endpoint("GET /users/:id", { output: User }), + * createUser: api.endpoint("POST /users", { input: NewUser, output: User }), + * login: api.endpoint("POST /auth/login", { + * input: Credentials, + * output: Session, + * plugins: { bearer: false }, + * }), + * }; + * }); + * + * class Api extends Context.Service>()("Api") {} + * + * const program = Effect.gen(function* () { + * const { getUser, createUser } = yield* Api; + * const ada = yield* createUser({ body: { name: "Ada" } }); + * return yield* getUser({ params: { id: ada.id } }); + * }); + * ``` + */ + readonly endpoint: EndpointMethod; } /** @@ -409,7 +475,9 @@ export const make = < ); const chain = applyPlugins(mapClientErrors(provided), plugins, context); const httpClient = HttpClient.mapRequestInput( - HttpClient.transformResponse(chain, provideOverrides(undefined)), + HttpClient.transformResponse(chain, (response) => + response.pipe(provideOverrides(undefined), provideRoute(undefined)), + ), applyDefaults, ); const call = makeCall({ @@ -433,6 +501,8 @@ export const make = < put: call("PUT"), patch: call("PATCH"), del: call("DELETE"), + endpoint: (route: string, definition?: RuntimeDefinition) => + makeEndpoint(call, route, definition), }; return client as unknown as Client; }); diff --git a/src/Endpoint.ts b/src/Endpoint.ts new file mode 100644 index 0000000..d0c067f --- /dev/null +++ b/src/Endpoint.ts @@ -0,0 +1,331 @@ +import type * as Headers from "effect/http/Headers"; +import type * as HttpMethod from "effect/http/HttpMethod"; + +import type { + AnySchema, + CallEffect, + ParamsOption, + QueryInput, + QueryOf, +} from "./Options.js"; +import type { Plugin } from "./Plugin.js"; + +/** + * The routes `endpoint` accepts: an uppercase HTTP method, one space, then + * a path that starts with `/` or an absolute `http://` or `https://` URL. + * + * @example + * ```ts + * import type * as Fetch from "@monarcode/effetch"; + * + * const route: Fetch.EndpointRoute = "GET /users/:id"; + * const absolute: Fetch.EndpointRoute = "POST https://uploads.example.com/files"; + * ``` + * + * @since 0.1.0 + */ +export type EndpointRoute = + | `${HttpMethod.HttpMethod} /${string}` + | `${HttpMethod.HttpMethod} http://${string}` + | `${HttpMethod.HttpMethod} https://${string}`; + +/** + * The type of `endpoint`'s route argument: `R` itself when it's an + * {@link EndpointRoute}, otherwise the list of `" /"` starts. + * + * A misspelled route is then a type error that shows what to write (`"GET + * /" | "POST /" | …`), and editors suggest the methods as you type. + * + * @example + * ```ts + * import type * as Fetch from "@monarcode/effetch"; + * + * type Ok = Fetch.ValidRoute<"GET /users/:id">; + * // ^? "GET /users/:id" + * type Typo = Fetch.ValidRoute<"GTE /users">; + * // ^? "GET /" | "POST /" | "PUT /" | … + * ``` + * + * @since 0.1.0 + */ +export type ValidRoute = R extends EndpointRoute + ? R + : `${HttpMethod.HttpMethod} /`; + +/** + * The HTTP method of route `R` (`"GET /users/:id"` gives `"GET"`). + * + * @example + * ```ts + * import type * as Fetch from "@monarcode/effetch"; + * + * type Method = Fetch.RouteMethod<"PATCH /users/:id">; + * // ^? "PATCH" + * ``` + * + * @since 0.1.0 + */ +export type RouteMethod = + R extends `${infer M extends HttpMethod.HttpMethod} ${string}` ? M : never; + +/** + * The path of route `R`, everything after the method and the space + * (`"GET /users/:id"` gives `"/users/:id"`). + * + * @example + * ```ts + * import type * as Fetch from "@monarcode/effetch"; + * + * type Path = Fetch.RoutePath<"GET /users/:id">; + * // ^? "/users/:id" + * ``` + * + * @since 0.1.0 + */ +export type RoutePath = + R extends `${HttpMethod.HttpMethod} ${infer P}` ? P : never; + +/** True when route `R`'s method never carries a body (Effect's `NoBody`). */ +type NoBodyRoute = [RouteMethod] extends [ + HttpMethod.HttpMethod.NoBody, +] + ? true + : false; + +/** + * What an endpoint is defined with, besides its route. Everything is + * optional. + * + * - `output`: decodes the 2xx JSON body. Without it, the endpoint succeeds + * with the raw `HttpClientResponse`. + * - `error`: decodes non-2xx bodies into `HttpError.body`, replacing the + * client's `defaultError`. + * - `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. + * - `headers`: sent with every call, over the client's headers. A call's + * own header of the same name wins. Unlike the client's headers, these + * can set `content-type`. + * - `plugins`: per-plugin overrides for every call, as in a call's + * `plugins` option. A call's own override for a plugin wins, and when + * both give options, they're merged. + * + * - `R`: the route, for the method and the path. + * - `O`, `EB`, `I`: the `output`, `error` and `input` schemas (`O` and `I` + * are `undefined` when absent). + * - `Ps`: the client's plugins. + * + * @example + * ```ts + * import * as Schema from "effect/Schema"; + * import type * as Fetch from "@monarcode/effetch"; + * + * const Credentials = Schema.Struct({ user: Schema.String, password: Schema.String }); + * const Session = Schema.Struct({ token: Schema.String }); + * + * const login: Fetch.EndpointDefinition< + * "POST /auth/login", + * typeof Session, + * Schema.Unknown, + * typeof Credentials, + * [Fetch.Plugin<"bearer">] + * > = { + * input: Credentials, + * output: Session, + * headers: { "x-client": "web" }, + * plugins: { bearer: false }, + * }; + * ``` + * + * @since 0.1.0 + */ +export interface EndpointDefinition< + R extends string, + O extends AnySchema | undefined, + EB extends AnySchema, + I extends AnySchema | undefined, + Ps extends ReadonlyArray, +> { + /** 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; + /** 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. */ + readonly headers?: Headers.Input | undefined; + /** Plugin overrides for every call. A call's own override wins. */ + readonly plugins?: Plugin.Overrides | undefined; +} + +/** + * The `body` option of an endpoint call: not allowed on `GET`, `HEAD`, + * `OPTIONS` and `TRACE` endpoints, required and typed by the schema when + * the endpoint has an `input`, and optional otherwise (sent according to + * its runtime type, like a call's `body`). + * + * @example + * ```ts + * import * as Schema from "effect/Schema"; + * import type * as Fetch from "@monarcode/effetch"; + * + * const NewTodo = Schema.Struct({ title: Schema.String }); + * + * const body: Fetch.EndpointBody<"POST /todos", typeof NewTodo> = { + * body: { title: "Write docs" }, + * }; + * const none: Fetch.EndpointBody<"GET /todos", undefined> = {}; + * ``` + * + * @since 0.1.0 + */ +export type EndpointBody = + NoBodyRoute extends true + ? { readonly body?: undefined } + : [I] extends [AnySchema] + ? { readonly body: I["Type"] } + : { readonly body?: unknown }; + +/** + * The options of one endpoint call: `params` for the route's path (see + * {@link ParamsOption}), `body` (see {@link EndpointBody}), `query`, + * `headers` and `plugins`. The schemas are fixed by the endpoint. + * + * - `Q`: the type of `query`, checked with {@link QueryOf}. Defaults to + * {@link QueryInput}. + * + * @example + * ```ts + * import type * as Fetch from "@monarcode/effetch"; + * + * const options: Fetch.EndpointOptions<"GET /orgs/:org/users", undefined, []> = { + * params: { org: "acme" }, + * query: { page: 2 }, + * headers: { "x-trace": "1" }, + * }; + * ``` + * + * @since 0.1.0 + */ +export type EndpointOptions< + R extends string, + I extends AnySchema | undefined, + Ps extends ReadonlyArray, + Q extends QueryOf = QueryInput, +> = ParamsOption> & + EndpointBody & { + readonly query?: Q | undefined; + readonly headers?: Headers.Input | undefined; + readonly plugins?: Plugin.Overrides | undefined; + }; + +/** + * The arguments of an endpoint call: the options object `T` is required + * when route `R` has path params or the endpoint has an `input` schema, + * and optional otherwise. + * + * @example + * ```ts + * import type * as Fetch from "@monarcode/effetch"; + * + * type One = Fetch.EndpointArgs<"GET /todos/:id", undefined, { params: { id: number } }>; + * // ^? [options: { params: { id: number } }] + * type All = Fetch.EndpointArgs<"GET /todos", undefined, {}>; + * // ^? [options?: {}] + * ``` + * + * @since 0.1.0 + */ +export type EndpointArgs = + Partial> extends Requirements + ? [options?: T] + : [options: T]; + +/** The options that can be required: `params` and `body`. */ +type Requirements< + R extends string, + I extends AnySchema | undefined, +> = ParamsOption> & EndpointBody; + +/** + * An endpoint made by `client.endpoint`: a function that sends its request + * with the options of one call, and returns the same Effect as the + * equivalent client method. + * + * It also carries its `route`, `method` and `path`, for things like log + * labels and cache keys. + * + * - `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. + * + * @example + * ```ts + * import * as Schema from "effect/Schema"; + * import * as Fetch from "@monarcode/effetch"; + * + * const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }); + * + * const users = (api: Fetch.Client) => { + * const getUser = api.endpoint("GET /users/:id", { output: User }); + * getUser.route; // "GET /users/:id" + * return getUser({ params: { id: 1 } }); + * }; + * ``` + * + * @since 0.1.0 + */ +export interface Endpoint< + R extends string, + O extends AnySchema | undefined, + EB extends AnySchema, + I extends AnySchema | undefined, + Ps extends ReadonlyArray, +> { + = QueryInput>( + ...args: EndpointArgs> + ): CallEffect; + /** The route the endpoint was defined with, such as `"GET /users/:id"`. */ + readonly route: R; + /** The route's HTTP method, such as `"GET"`. */ + readonly method: RouteMethod; + /** The route's path, with its `:params` unfilled, such as `"/users/:id"`. */ + readonly path: RoutePath; +} + +/** + * The signature of `client.endpoint`: defines an {@link Endpoint} from a + * route and an {@link EndpointDefinition}. + * + * - `Ps`: the client's plugins. + * - `DefaultEB`: the client's `defaultError` schema, used when the + * endpoint has no `error`. + * + * @example + * ```ts + * import * as Schema from "effect/Schema"; + * import type * as Fetch from "@monarcode/effetch"; + * + * declare const endpoint: Fetch.EndpointMethod<[], Schema.Unknown>; + * + * const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); + * const getTodo = endpoint("GET /todos/:id", { output: Todo }); + * ``` + * + * @since 0.1.0 + */ +export interface EndpointMethod< + Ps extends ReadonlyArray, + DefaultEB extends AnySchema, +> { + < + const R extends string, + O extends AnySchema | undefined = undefined, + EB extends AnySchema = DefaultEB, + I extends AnySchema | undefined = undefined, + >( + route: ValidRoute, + definition?: EndpointDefinition, + ): Endpoint; +} diff --git a/src/Options.ts b/src/Options.ts index f17ebf4..4979712 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -352,11 +352,11 @@ export type CallError< export type CallServices< O extends AnySchema | undefined, EB extends AnySchema, - I extends AnySchema, + I extends AnySchema | undefined, > = | (O extends AnySchema ? O["DecodingServices"] : never) | EB["DecodingServices"] - | I["EncodingServices"]; + | (I extends AnySchema ? I["EncodingServices"] : never); /** * The Effect a call returns: it succeeds with {@link Success}, fails with @@ -364,7 +364,8 @@ export type CallServices< * * - `O`: the `output` schema, or `undefined` for the raw response. * - `EB`: the `error` schema for non-2xx bodies. - * - `I`: the `input` schema (`Schema.Unknown` when the call has none). + * - `I`: the `input` schema (`Schema.Unknown` or `undefined` when the call + * has none). * - `Ps`: the client's plugins. * * @example @@ -386,7 +387,7 @@ export type CallServices< export type CallEffect< O extends AnySchema | undefined, EB extends AnySchema, - I extends AnySchema, + I extends AnySchema | undefined, Ps extends ReadonlyArray, > = Effect.Effect, CallError, CallServices>; diff --git a/src/Plugin.ts b/src/Plugin.ts index 118bd1a..ef2ebc0 100644 --- a/src/Plugin.ts +++ b/src/Plugin.ts @@ -52,6 +52,11 @@ declare const UpstreamServicesTypeId: unique symbol; * `{ ...defaults, ...override }`, where `undefined` values in the * override keep the default. `undefined` for a plugin without * `defaults`. + * - `route`: the call's method and path as written, before its params are + * filled in, such as `"GET /users/:id"`. It's the same for every call to + * that path, whether made with a method like `get` or an endpoint, so + * logs and metrics can be grouped by it. `undefined` for requests sent + * through `client.httpClient`. * * @example * ```ts @@ -63,7 +68,10 @@ declare const UpstreamServicesTypeId: unique symbol; * defaults: { header: "x-request-id" }, * onResponse: (response, ctx: Fetch.PluginContext<{ header: string }>) => * Effect.as( - * Effect.logDebug(ctx.request.url, response.headers[ctx.options.header]), + * Effect.logDebug( + * ctx.route ?? ctx.request.url, + * response.headers[ctx.options.header], + * ), * response, * ), * }); @@ -76,6 +84,11 @@ export interface PluginContext { readonly request: HttpClientRequest.HttpClientRequest; /** The plugin's `defaults` merged with this call's override. */ readonly options: Opts; + /** + * The call's method and unfilled path, such as `"GET /users/:id"`. + * `undefined` for requests sent through `client.httpClient`. + */ + readonly route: string | undefined; } /** @@ -230,18 +243,32 @@ export declare namespace Plugin { * 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 * services but can't drop the ones from further in. `options` reads - * this call's merged options. + * this call's merged options, and `route` this call's route (see + * {@link PluginContext}). * * Write the body with `Effect.gen` rather than a ternary that returns * two different Effects: TypeScript can't union their error types. * * @example * ```ts + * import * as Effect from "effect/Effect"; * import * as HttpClient from "effect/http/HttpClient"; * import type * as Fetch from "@monarcode/effetch"; * * const retryTwice: Fetch.Plugin.Wrap = (client) => * HttpClient.retry(client, { times: 2 }); + * + * const timed: Fetch.Plugin.Wrap = ( + * client, + * _options, + * route, + * ) => + * HttpClient.transform(client, (response, request) => + * Effect.gen(function* () { + * const label = (yield* route) ?? request.url; + * return yield* Effect.withLogSpan(response, label); + * }), + * ); * ``` * * @since 0.1.0 @@ -249,6 +276,7 @@ export declare namespace Plugin { export type Wrap = ( client: Upstream, options: Effect.Effect, + route: Effect.Effect, ) => HttpClient.HttpClient.With; /** @@ -291,6 +319,7 @@ export declare namespace Plugin { | (( client: Upstream, options: Effect.Effect, + route: Effect.Effect, ) => HttpClient.HttpClient.With) | undefined; } diff --git a/src/index.ts b/src/index.ts index a4353f1..cd49bbc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,17 @@ export { make } from "./Client.js"; export type { Client, ClientConfig } from "./Client.js"; +export type { + Endpoint, + EndpointArgs, + EndpointBody, + EndpointDefinition, + EndpointMethod, + EndpointOptions, + EndpointRoute, + RouteMethod, + RoutePath, + ValidRoute, +} from "./Endpoint.js"; export { DecodeError, HttpError, diff --git a/src/internal/endpoint.ts b/src/internal/endpoint.ts new file mode 100644 index 0000000..be7c24f --- /dev/null +++ b/src/internal/endpoint.ts @@ -0,0 +1,80 @@ +import * as Headers from "effect/http/Headers"; +import * as HttpMethod from "effect/http/HttpMethod"; + +import type { AnySchema } from "../Options.js"; +import { mergeOverrides, type OverrideMap } from "./plugins.js"; +import type { Call, RuntimeOptions } from "./request.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 input?: AnySchema | undefined; + readonly headers?: Headers.Input | undefined; + readonly plugins?: OverrideMap | undefined; +} + +/** An endpoint, as seen at runtime. */ +export interface RuntimeEndpoint { + (options?: RuntimeOptions): ReturnType; + readonly route: string; + readonly method: HttpMethod.HttpMethod; + readonly path: string; +} + +/** + * Splits `route` into its method and path, with the same rules as the + * `EndpointRoute` type: an uppercase method, one space, then a path that + * starts with `/` or is an absolute `http(s)://` URL. + * + * Throws on anything else. The types reject such routes, so this only + * catches casts and JavaScript callers. + */ +export const parseRoute = ( + route: string, +): { readonly method: HttpMethod.HttpMethod; readonly path: string } => { + const space = typeof route === "string" ? route.indexOf(" ") : -1; + const method = space === -1 ? "" : route.slice(0, space); + const path = space === -1 ? "" : route.slice(space + 1); + if ( + !HttpMethod.isHttpMethod(method) || + !(path.startsWith("/") || isAbsoluteUrl(path)) + ) { + throw new Error( + `Invalid endpoint route ${JSON.stringify(route)}: expected an uppercase HTTP method, one space and a path that starts with "/" or "http(s)://", such as "GET /users/:id"`, + ); + } + return { method, path }; +}; + +/** + * Makes an endpoint: a function that sends `route` with `definition` and + * one call's options on top. The definition's schemas are fixed; its + * headers and plugin overrides are defaults the call can override. + */ +export const makeEndpoint = ( + call: (method: HttpMethod.HttpMethod) => Call, + route: string, + definition: RuntimeDefinition = {}, +): RuntimeEndpoint => { + const { method, path } = parseRoute(route); + const send = call(method); + const headers = + definition.headers === undefined + ? undefined + : Headers.fromInput(definition.headers); + const endpoint = (options: RuntimeOptions = {}) => + send(path, { + ...options, + output: definition.output, + error: definition.error, + input: definition.input, + headers: + headers === undefined || options.headers === undefined + ? (options.headers ?? headers) + : Headers.merge(headers, Headers.fromInput(options.headers)), + plugins: mergeOverrides(definition.plugins, options.plugins), + }); + return Object.assign(endpoint, { route, method, path }); +}; diff --git a/src/internal/plugins.ts b/src/internal/plugins.ts index 2523da7..cf6daea 100644 --- a/src/internal/plugins.ts +++ b/src/internal/plugins.ts @@ -34,6 +34,23 @@ export const provideOverrides = ( ): ((self: Effect.Effect) => Effect.Effect) => Effect.provideService(CurrentOverrides, overrides ?? noOverrides); +/** + * The route of the call being run, such as `"GET /users/:id"`. Provided + * around each call; anything else (such as `client.httpClient`) sees + * `undefined`. + */ +export const CurrentRoute: Context.Reference = + Context.Reference("@monarcode/effetch/CurrentRoute", { + defaultValue: () => undefined, + }); + +export const provideRoute = ( + route: string | undefined, +): ((self: Effect.Effect) => Effect.Effect) => + Effect.provideService(CurrentRoute, route); + +const currentRoute = Effect.service(CurrentRoute); + const overrideOf = (overrides: OverrideMap, name: string): unknown => hasOwn(overrides, name) ? overrides[name] : undefined; @@ -57,6 +74,37 @@ export const mergeOptions = (defaults: unknown, override: unknown): unknown => } : defaults; +/** + * An endpoint's `plugins` option with a call's on top, per plugin name. The + * call's override wins, except that when both are options objects they're + * merged with {@link mergeOptions}. `Object.fromEntries` defines own + * properties, so even a plugin named `__proto__` stays a plain key. + */ +export const mergeOverrides = ( + endpoint: OverrideMap | undefined, + call: OverrideMap | undefined, +): OverrideMap | undefined => { + if (endpoint === undefined || call === undefined) { + return call ?? endpoint; + } + const names = new Set([...Object.keys(endpoint), ...Object.keys(call)]); + return Object.fromEntries( + Array.from(names, (name) => { + const base = overrideOf(endpoint, name); + const override = overrideOf(call, name); + if (override === undefined) { + return [name, base]; + } + return [ + name, + isRecord(base) && isRecord(override) + ? mergeOptions(base, override) + : override, + ]; + }), + ); +}; + const fromExecute = ( execute: ( request: HttpClientRequest.HttpClientRequest, @@ -99,12 +147,14 @@ const layer = (plugin: Plugin.Any, inner: Execute): Execute => { : (request) => Effect.gen(function* () { const current = yield* options; + const route = yield* currentRoute; const sent = onRequest === undefined ? request : yield* onRequest(request, { request, options: current, + route, }); const response = yield* inner(sent); return onResponse === undefined @@ -112,12 +162,19 @@ const layer = (plugin: Plugin.Any, inner: Execute): Execute => { : yield* onResponse(response, { request: sent, options: current, + route, }); }); const wrapped: Execute = wrap === undefined ? hooked - : executeOf(wrap(fromExecute(hooked) as Plugin.Upstream, options)); + : executeOf( + wrap( + fromExecute(hooked) as Plugin.Upstream, + options, + currentRoute, + ), + ); return (request) => Effect.flatMap(Effect.service(CurrentOverrides), (overrides) => overrideOf(overrides, plugin.name) === false @@ -172,8 +229,10 @@ export const runErrorHooks = ( return Effect.void; } const error = Cause.squash(cause); - const hooks = Effect.flatMap(Effect.service(CurrentOverrides), (overrides) => - Effect.forEach( + const hooks = Effect.gen(function* () { + const overrides = yield* Effect.service(CurrentOverrides); + const route = yield* currentRoute; + yield* Effect.forEach( plugins, (plugin) => { const override = overrideOf(overrides, plugin.name); @@ -182,7 +241,7 @@ export const runErrorHooks = ( } const options = mergeOptions(plugin.defaults, override); return Effect.catchCause( - plugin.onError(error, { request, options }), + plugin.onError(error, { request, options, route }), (failure) => Effect.logDebug( `effetch: the onError hook of plugin "${plugin.name}" failed`, @@ -191,7 +250,7 @@ export const runErrorHooks = ( ); }, { discard: true }, - ), - ); + ); + }); return withContext(context)(hooks); }; diff --git a/src/internal/request.ts b/src/internal/request.ts index 6e02e96..e17ae26 100644 --- a/src/internal/request.ts +++ b/src/internal/request.ts @@ -14,6 +14,7 @@ import type { ApplyDefaults } from "./pipeline.js"; import { type OverrideMap, provideOverrides, + provideRoute, runErrorHooks, } from "./plugins.js"; import { toUrlParams } from "./query.js"; @@ -102,7 +103,10 @@ const send = ( return yield* Effect.tapCause(execute(state, request, options), (cause) => runErrorHooks(state.plugins, state.context, request, cause), ); - }).pipe(provideOverrides(options.plugins)); + }).pipe( + provideOverrides(options.plugins), + provideRoute(`${method} ${path}`), + ); export type Call = ( path: string, diff --git a/src/plugins/logger.ts b/src/plugins/logger.ts index 5e66b87..fb30271 100644 --- a/src/plugins/logger.ts +++ b/src/plugins/logger.ts @@ -50,8 +50,11 @@ export type LoggerPlugin = Plugin<"logger">; * with the failure's cause. * * The same values are log annotations (`method`, `url`, `status`, - * `durationMs`). The URL has no query string, and no headers are logged, - * so tokens and cookies stay out of logs. The duration is measured with + * `durationMs`), along with `route`: the call's method and unfilled path, + * such as `GET /users/:id`, the same for every call to that path, to group + * entries by (left out for requests sent through `client.httpClient`). The + * URL has no query string, and no headers are logged, so tokens and + * cookies stay out of logs. The duration is measured with * Effect's `Clock` until the response headers arrive, in whole * milliseconds. * @@ -75,7 +78,7 @@ export const logger = (options: LoggerOptions = {}): LoggerPlugin => { const log = options.level === "Info" ? Effect.logInfo : Effect.logDebug; return plugin({ name: "logger", - wrap: (client) => + wrap: (client, _options, currentRoute) => HttpClient.transform(client, (response, request) => Effect.gen(function* () { const start = yield* Clock.currentTimeMillis; @@ -85,11 +88,13 @@ export const logger = (options: LoggerOptions = {}): LoggerPlugin => { ); const method = request.method; const url = errorUrl(request.url); + const route = yield* currentRoute; + const call = route === undefined ? {} : { route }; if (Exit.isSuccess(exit)) { const { status } = exit.value; yield* Effect.annotateLogs( log(`${method} ${url} ${status} (${durationMs}ms)`), - { method, url, status, durationMs }, + { method, url, ...call, status, durationMs }, ); } else if (!Cause.hasInterruptsOnly(exit.cause)) { yield* Effect.annotateLogs( @@ -97,7 +102,7 @@ export const logger = (options: LoggerOptions = {}): LoggerPlugin => { `${method} ${url} failed (${durationMs}ms)`, exit.cause, ), - { method, url, durationMs }, + { method, url, ...call, durationMs }, ); } return yield* exit; diff --git a/test/endpoint.test.ts b/test/endpoint.test.ts new file mode 100644 index 0000000..42c50c4 --- /dev/null +++ b/test/endpoint.test.ts @@ -0,0 +1,392 @@ +import { it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as Schema from "effect/Schema"; + +import { describe, expect } from "vitest"; + +import * as Fetch from "../src/index.js"; +import { mergeOverrides } from "../src/internal/plugins.js"; +import { base, setup, wire } from "./utils/client.js"; +import type { RecordedRequest } from "./utils/mockClient.js"; +import { reply } from "./utils/reply.js"; + +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); +const NewTodo = Schema.Struct({ title: Schema.String, due: Schema.Date }); +const ApiError = Schema.Struct({ code: Schema.String }); + +const todo = { id: 1, title: "Write docs" }; + +/** What was sent, without the tracing headers, which differ per request. */ +const sent = ({ method, url, headers, body }: RecordedRequest) => ({ + method, + url, + headers: Object.fromEntries( + Object.entries(headers).filter( + ([name]) => name !== "traceparent" && name !== "b3", + ), + ), + body, +}); + +/** Sets `x-a` and `x-b` from its options, so tests can see them. */ +const flags = Fetch.plugin({ + name: "flags", + defaults: { a: "1", b: "2" }, + onRequest: (request, ctx) => + Effect.succeed( + HttpClientRequest.setHeaders(request, { + "x-a": ctx.options.a, + "x-b": ctx.options.b, + }), + ), +}); + +/** Sets `x-tag`, and has no options: calls can only skip it. */ +const tag = Fetch.plugin({ + name: "tag", + onRequest: (request) => + Effect.succeed(HttpClientRequest.setHeader(request, "x-tag", "yes")), +}); + +describe("calls", () => { + it.effect("send what the equivalent method sends", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json(todo)]); + const getTodo = api.endpoint("GET /todos/:id", { output: Todo }); + const viaEndpoint = yield* getTodo({ + params: { id: 1 }, + query: { expand: "owner" }, + headers: { "x-trace": "1" }, + }); + const viaMethod = yield* api.get("/todos/:id", { + params: { id: 1 }, + query: { expand: "owner" }, + headers: { "x-trace": "1" }, + output: Todo, + }); + expect(viaEndpoint).toEqual(todo); + expect(viaMethod).toEqual(todo); + expect(sent(wire(mock, 0))).toEqual(sent(wire(mock, 1))); + expect(wire(mock, 0)).toMatchObject({ + method: "GET", + url: `${base}/todos/1?expand=owner`, + }); + }), + ); + + it.effect("encode the body with the input schema", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json(todo)]); + const create = api.endpoint("POST /todos", { + input: NewTodo, + output: Todo, + }); + const due = new Date("2026-01-02T03:04:05.000Z"); + yield* create({ body: { title: "Write docs", due } }); + expect(wire(mock)).toMatchObject({ + method: "POST", + url: `${base}/todos`, + body: JSON.stringify({ + title: "Write docs", + due: "2026-01-02T03:04:05.000Z", + }), + }); + expect(wire(mock).headers["content-type"]).toBe("application/json"); + }), + ); + + it.effect("send a body by its type without an input schema", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.empty()]); + const note = api.endpoint("PUT /notes/:id"); + yield* note({ params: { id: "a b" }, body: "hello" }); + expect(wire(mock)).toMatchObject({ + method: "PUT", + url: `${base}/notes/a%20b`, + body: "hello", + }); + expect(wire(mock).headers["content-type"]).toBe("text/plain"); + }), + ); + + it.effect("succeed with the raw response without output", () => + Effect.gen(function* () { + const { api } = yield* setup([reply.empty(204)]); + const remove = api.endpoint("DELETE /todos/:id"); + const response = yield* remove({ params: { id: 1 } }); + expect(HttpClientResponse.TypeId in response).toBe(true); + expect(response.status).toBe(204); + }), + ); + + it.effect("take no arguments when there's nothing to pass", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json([todo])]); + const list = api.endpoint("GET /todos", { + output: Schema.Array(Todo), + }); + expect(yield* list()).toEqual([todo]); + expect(wire(mock).url).toBe(`${base}/todos`); + }), + ); + + it.effect("send a new request every time", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json(todo)]); + const getTodo = api.endpoint("GET /todos/:id", { output: Todo }); + yield* Effect.all( + [getTodo({ params: { id: 1 } }), getTodo({ params: { id: 2 } })], + { concurrency: "unbounded" }, + ); + yield* getTodo({ params: { id: 3 } }); + expect(mock.requests.map((request) => request.url).sort()).toEqual([ + `${base}/todos/1`, + `${base}/todos/2`, + `${base}/todos/3`, + ]); + }), + ); + + it.effect("use an absolute URL route as it is", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.empty()]); + const upload = api.endpoint("POST https://uploads.test/files/:name"); + yield* upload({ params: { name: "a.txt" }, body: "x" }); + expect(wire(mock).url).toBe("https://uploads.test/files/a.txt"); + }), + ); +}); + +describe("errors", () => { + it.effect("decode error bodies with the endpoint's error schema", () => + Effect.gen(function* () { + const { api } = yield* setup( + [reply.json({ code: "gone" }, { status: 404 })], + { defaultError: Schema.Struct({ other: Schema.String }) }, + ); + const getTodo = api.endpoint("GET /todos/:id", { + output: Todo, + error: ApiError, + }); + const error = yield* Effect.flip(getTodo({ params: { id: 1 } })); + expect(error).toBeInstanceOf(Fetch.HttpError); + expect(error).toMatchObject({ status: 404, body: { code: "gone" } }); + }), + ); + + it.effect("fall back to the client's default error schema", () => + Effect.gen(function* () { + const { api } = yield* setup( + [reply.json({ code: "gone" }, { status: 404 })], + { defaultError: ApiError }, + ); + const getTodo = api.endpoint("GET /todos/:id", { output: Todo }); + const error = yield* Effect.flip(getTodo({ params: { id: 1 } })); + expect(error).toMatchObject({ status: 404, body: { code: "gone" } }); + }), + ); + + it.effect("fail with DecodeError when output doesn't match", () => + Effect.gen(function* () { + const { api } = yield* setup([reply.json({ id: "one" })]); + const getTodo = api.endpoint("GET /todos/:id", { output: Todo }); + const error = yield* Effect.flip(getTodo({ params: { id: 1 } })); + expect(error).toBeInstanceOf(Fetch.DecodeError); + }), + ); +}); + +describe("headers", () => { + it.effect("are sent over the client's, and a call's win", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json({})], { + headers: { "x-client": "client", "x-shared": "client" }, + }); + const list = api.endpoint("GET /todos", { + headers: { "X-Shared": "endpoint", "x-endpoint": "endpoint" }, + }); + yield* list(); + yield* list({ headers: { "x-shared": "call", "X-Endpoint": "call" } }); + expect(wire(mock, 0).headers).toMatchObject({ + "x-client": "client", + "x-shared": "endpoint", + "x-endpoint": "endpoint", + }); + expect(wire(mock, 1).headers).toMatchObject({ + "x-client": "client", + "x-shared": "call", + "x-endpoint": "call", + }); + }), + ); + + it.effect("can set the content type, over the body's", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json({})]); + const patch = api.endpoint("PATCH /todos/:id", { + headers: { "content-type": "application/merge-patch+json" }, + }); + yield* patch({ params: { id: 1 }, body: { done: true } }); + expect(wire(mock).headers["content-type"]).toBe( + "application/merge-patch+json", + ); + }), + ); + + it.effect("ask for JSON with an output, unless they set accept", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json(todo)]); + yield* api.endpoint("GET /todos/:id", { output: Todo })({ + params: { id: 1 }, + }); + yield* api.endpoint("GET /todos/:id", { + output: Todo, + headers: { accept: "application/vnd.api+json" }, + })({ params: { id: 1 } }); + expect(wire(mock, 0).headers["accept"]).toBe("application/json"); + expect(wire(mock, 1).headers["accept"]).toBe( + "application/vnd.api+json", + ); + }), + ); +}); + +describe("plugin overrides", () => { + it.effect("apply to every call of the endpoint", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json({})], { + plugins: [flags, tag], + }); + const login = api.endpoint("POST /login", { + plugins: { tag: false, flags: { a: "endpoint" } }, + }); + yield* login(); + yield* api.post("/other"); + expect(wire(mock, 0).headers["x-tag"]).toBeUndefined(); + expect(wire(mock, 0).headers).toMatchObject({ + "x-a": "endpoint", + "x-b": "2", + }); + expect(wire(mock, 1).headers).toMatchObject({ + "x-tag": "yes", + "x-a": "1", + }); + }), + ); + + it.effect("merge with a call's, whose options win", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json({})], { + plugins: [flags], + }); + const list = api.endpoint("GET /todos", { + plugins: { flags: { a: "endpoint", b: "endpoint" } }, + }); + const withUndefined = { a: "call", b: undefined } as { + readonly a: string; + }; + yield* list({ plugins: { flags: { b: "call" } } }); + yield* list({ plugins: { flags: withUndefined } }); + expect(wire(mock, 0).headers).toMatchObject({ + "x-a": "endpoint", + "x-b": "call", + }); + expect(wire(mock, 1).headers).toMatchObject({ + "x-a": "call", + "x-b": "endpoint", + }); + }), + ); + + it.effect("let a call skip a plugin, or turn it back on", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json({})], { + plugins: [flags], + }); + const configured = api.endpoint("GET /a", { + plugins: { flags: { a: "endpoint" } }, + }); + const skipped = api.endpoint("GET /b", { plugins: { flags: false } }); + yield* configured({ plugins: { flags: false } }); + yield* skipped({ plugins: { flags: { b: "call" } } }); + expect(wire(mock, 0).headers["x-a"]).toBeUndefined(); + expect(wire(mock, 1).headers).toMatchObject({ + "x-a": "1", + "x-b": "call", + }); + }), + ); + + it("are merged per plugin name, as own keys only", () => { + const endpoint = JSON.parse('{"__proto__": false, "a": {"x": 1}}'); + const merged = mergeOverrides(endpoint, { a: { y: 2 } }); + expect(Object.getPrototypeOf(merged)).toBe(Object.prototype); + expect(Object.hasOwn(merged ?? {}, "__proto__")).toBe(true); + expect(merged).toMatchObject({ a: { x: 1, y: 2 } }); + expect(mergeOverrides(undefined, { a: false })).toEqual({ a: false }); + expect(mergeOverrides({ a: false }, undefined)).toEqual({ a: false }); + }); +}); + +describe("the endpoint", () => { + it.effect("carries its route, method and path", () => + Effect.gen(function* () { + const { api } = yield* setup([reply.json({})]); + const getUser = api.endpoint("GET /users/:id"); + expect(getUser.route).toBe("GET /users/:id"); + expect(getUser.method).toBe("GET"); + expect(getUser.path).toBe("/users/:id"); + }), + ); + + it.effect("keeps its schemas, whatever a call passes", () => + Effect.gen(function* () { + const { api } = yield* setup([reply.json(todo)]); + const raw = api.endpoint("GET /todos/:id") as unknown as ( + options: object, + ) => Effect.Effect; + const response = yield* raw({ params: { id: 1 }, output: Todo }); + expect(response).toHaveProperty([HttpClientResponse.TypeId]); + }), + ); + + it.effect.each([ + "get /todos", + "GTE /todos", + "GET/todos", + "GET /todos", + "GET todos", + "/todos", + "", + 42, + ])("throws for the invalid route %j", (route) => + Effect.gen(function* () { + const { api } = yield* setup([reply.json({})]); + expect(() => api.endpoint(route as "GET /")).toThrow( + /^Invalid endpoint route .*: expected an uppercase HTTP method/, + ); + }), + ); + + it.effect("dies on a body for a method without one, like api.get", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json({})]); + const list = api.endpoint("GET /todos") as unknown as ( + options: object, + ) => Effect.Effect; + const exit = yield* Effect.exit(list({ body: { a: 1 } })); + expect(Exit.hasDies(exit)).toBe(true); + const defect = Exit.isFailure(exit) + ? Cause.squash(exit.cause) + : undefined; + expect((defect as Error).message).toBe( + "A GET request can't have a body", + ); + expect(mock.requests).toHaveLength(0); + }), + ); +}); diff --git a/test/plugins.test.ts b/test/plugins.test.ts index 57b9e83..c5c35b5 100644 --- a/test/plugins.test.ts +++ b/test/plugins.test.ts @@ -12,7 +12,7 @@ import * as Schema from "effect/Schema"; import { describe, expect } from "vitest"; import * as Fetch from "../src/index.js"; -import { CurrentOverrides } from "../src/internal/plugins.js"; +import { CurrentOverrides, CurrentRoute } from "../src/internal/plugins.js"; import { base, setup, wire } from "./utils/client.js"; import { captureLogs } from "./utils/logs.js"; import { failure, hang, type Script } from "./utils/mockClient.js"; @@ -564,6 +564,128 @@ describe("make", () => { ); }); +/** Records the route each hook sees, as `" "`. */ +const routeRecorder = (seen: Array) => + Fetch.plugin({ + name: "routes", + onRequest: (request, ctx) => + Effect.sync(() => { + seen.push(`onRequest ${ctx.route}`); + return request; + }), + onResponse: (response, ctx) => + Effect.sync(() => { + seen.push(`onResponse ${ctx.route}`); + return response; + }), + onError: (_error, ctx) => + Effect.sync(() => { + seen.push(`onError ${ctx.route}`); + }), + wrap: (client, _options, route) => + HttpClient.transform(client, (response) => + Effect.gen(function* () { + seen.push(`wrap ${yield* route}`); + return yield* response; + }), + ), + }); + +describe("route", () => { + it.effect("is the call's method and unfilled path, in every hook", () => + Effect.gen(function* () { + const seen: Array = []; + const { api } = yield* setup([reply.empty(500)], { + plugins: [routeRecorder(seen)], + }); + yield* Effect.flip(api.get("/todos/:id", { params: { id: 1 } })); + expect(seen).toEqual([ + "wrap GET /todos/:id", + "onRequest GET /todos/:id", + "onResponse GET /todos/:id", + "onError GET /todos/:id", + ]); + }), + ); + + it.effect("is the same for every method and for endpoints", () => + Effect.gen(function* () { + const seen: Array = []; + const { api } = yield* setup([reply.json({})], { + plugins: [routeRecorder(seen)], + }); + yield* api.request("PATCH", "/todos/:id", { params: { id: 1 } }); + yield* api.endpoint("PATCH /todos/:id")({ params: { id: 2 } }); + yield* api.post("https://other.test/upload"); + const onRequest = seen.filter((entry) => + entry.startsWith("onRequest"), + ); + expect(onRequest).toEqual([ + "onRequest PATCH /todos/:id", + "onRequest PATCH /todos/:id", + "onRequest POST https://other.test/upload", + ]); + }), + ); + + it.effect("belongs to its own call, even concurrently", () => + Effect.gen(function* () { + const seen: Array = []; + const { api } = yield* setup([reply.json({})], { + plugins: [routeRecorder(seen)], + }); + yield* Effect.all( + [api.get("/a"), api.del("/b/:id", { params: { id: 1 } })], + { + concurrency: "unbounded", + }, + ); + expect( + seen.filter((entry) => entry.startsWith("onRequest")).sort(), + ).toEqual(["onRequest DELETE /b/:id", "onRequest GET /a"]); + }), + ); + + it.effect("is undefined for httpClient, even inside a call", () => + Effect.gen(function* () { + const seen: Array = []; + const inner: { + send?: + | (( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect) + | undefined; + } = {}; + const nested = Fetch.plugin({ + name: "nested", + onRequest: (request, ctx) => + Effect.gen(function* () { + // Only once, so a leaked route fails the test instead of looping. + const send = inner.send; + if (ctx.route === "GET /outer" && send !== undefined) { + inner.send = undefined; + yield* Effect.orDie(send(HttpClientRequest.get("/inner"))); + } + return request; + }), + }); + const { mock, api } = yield* setup([reply.json({})], { + plugins: [routeRecorder(seen), nested], + }); + inner.send = (request) => api.httpClient.execute(request); + yield* api.get("/outer"); + expect(mock.requests.map((request) => request.url)).toEqual([ + `${base}/inner`, + `${base}/outer`, + ]); + expect(seen.filter((entry) => entry.startsWith("onRequest"))).toEqual([ + "onRequest GET /outer", + "onRequest undefined", + ]); + }), + ); +}); + describe("httpClient", () => { it.effect("runs every plugin with its defaults, outside any call", () => Effect.gen(function* () { @@ -578,6 +700,23 @@ describe("httpClient", () => { }), ); + it.effect("gives plugins no route, outside any call", () => + Effect.gen(function* () { + const seen: Array = []; + const { api } = yield* setup([reply.json({})], { + plugins: [routeRecorder(seen)], + }); + yield* api.httpClient + .execute(HttpClientRequest.get("/todos")) + .pipe(Effect.provideService(CurrentRoute, "GET /elsewhere")); + expect(seen).toEqual([ + "wrap undefined", + "onRequest undefined", + "onResponse undefined", + ]); + }), + ); + it.effect("fails with the plugins' errors", () => Effect.gen(function* () { const { api } = yield* setup([reply.empty(401)], { diff --git a/test/plugins/logger.test.ts b/test/plugins/logger.test.ts index 13cc407..c050778 100644 --- a/test/plugins/logger.test.ts +++ b/test/plugins/logger.test.ts @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; import { describe, expect } from "vitest"; @@ -17,7 +18,7 @@ import { reply } from "../utils/reply.js"; describe("logger", () => { it.effect( - "logs the method, URL, status and duration at Debug, as text and annotations", + "logs the method, URL, status and duration at Debug, as text and annotations, with the route", () => Effect.gen(function* () { const logs = captureLogs(); @@ -35,6 +36,7 @@ describe("logger", () => { annotations: { method: "GET", url: `${base}/todos`, + route: "GET /todos", status: 200, durationMs: 120, }, @@ -42,6 +44,39 @@ describe("logger", () => { }), ); + it.effect("annotates the route with its params unfilled", () => + Effect.gen(function* () { + const logs = captureLogs(); + const { api } = yield* setup([reply.json({})], { + plugins: [logger()], + }); + const getTodo = api.endpoint("GET /todos/:id"); + yield* logs.capture(api.patch("/todos/:id", { params: { id: 1 } })); + yield* logs.capture(getTodo({ params: { id: 2 } })); + expect(logs.entries.map((entry) => entry.message)).toEqual([ + [`PATCH ${base}/todos/1 200 (0ms)`], + [`GET ${base}/todos/2 200 (0ms)`], + ]); + expect( + logs.entries.map((entry) => entry.annotations["route"]), + ).toEqual(["PATCH /todos/:id", "GET /todos/:id"]); + }), + ); + + it.effect("leaves the route out for client.httpClient", () => + Effect.gen(function* () { + const logs = captureLogs(); + const { api } = yield* setup([reply.json({})], { + plugins: [logger()], + }); + yield* logs.capture( + api.httpClient.execute(HttpClientRequest.get("/todos")), + ); + expect(logs.entries).toHaveLength(1); + expect(logs.entries[0]?.annotations).not.toHaveProperty("route"); + }), + ); + it.effect("rounds the duration to whole milliseconds", () => Effect.gen(function* () { const logs = captureLogs(); @@ -83,6 +118,7 @@ describe("logger", () => { expect(logs.entries[0]?.annotations).toMatchObject({ method: "GET", url: `${base}/todos`, + route: "GET /todos", durationMs: 0, }); const cause = logs.entries[0]?.cause; diff --git a/test/types/endpoint.test-d.ts b/test/types/endpoint.test-d.ts new file mode 100644 index 0000000..fb4508e --- /dev/null +++ b/test/types/endpoint.test-d.ts @@ -0,0 +1,356 @@ +// Type tests build Effects only to check their types; none are run. +// @effect-diagnostics floatingEffect:off + +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import type * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; + +import { describe, expectTypeOf, it } from "vitest"; + +import type { Client } from "../../src/Client.js"; +import type { + Endpoint, + EndpointArgs, + EndpointMethod, + EndpointRoute, + RouteMethod, + RoutePath, + ValidRoute, +} from "../../src/Endpoint.js"; +import type { DecodeError, HttpError, NetworkError } from "../../src/Errors.js"; +import type { Plugin } from "../../src/Plugin.js"; + +class TimeoutError extends Data.TaggedError("TimeoutError")<{}> {} + +type Ps = [ + Plugin<"bearer">, + Plugin<"retry", never, never, { times: number; methods: Array }>, + Plugin<"timeout", TimeoutError, never, { duration: string }>, +]; + +declare const endpoint: EndpointMethod; + +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); +const NewTodo = Schema.Struct({ title: Schema.String, due: Schema.Date }); +const ApiError = Schema.Struct({ code: Schema.String }); + +type Todo = typeof Todo.Type; +type Response = HttpClientResponse.HttpClientResponse; + +describe("route", () => { + it("gives the endpoint its method and path", () => { + const getTodo = endpoint("GET /todos/:id"); + expectTypeOf(getTodo.route).toEqualTypeOf<"GET /todos/:id">(); + expectTypeOf(getTodo.method).toEqualTypeOf<"GET">(); + expectTypeOf(getTodo.path).toEqualTypeOf<"/todos/:id">(); + }); + + it("can be an absolute URL", () => { + const upload = endpoint("POST https://uploads.test/files/:name"); + expectTypeOf(upload.method).toEqualTypeOf<"POST">(); + expectTypeOf( + upload.path, + ).toEqualTypeOf<"https://uploads.test/files/:name">(); + upload({ params: { name: "a.txt" } }); + }); + + it("accepts every HTTP method", () => { + endpoint("PUT /x"); + endpoint("PATCH /x"); + endpoint("DELETE /x"); + endpoint("HEAD /x"); + endpoint("OPTIONS /x"); + endpoint("TRACE /x"); + endpoint("QUERY /x"); + }); + + it("rejects malformed routes", () => { + // @ts-expect-error misspelled method + endpoint("GTE /todos"); + // @ts-expect-error lowercase method + endpoint("get /todos"); + // @ts-expect-error no space + endpoint("GET/todos"); + // @ts-expect-error two spaces + endpoint("GET /todos"); + // @ts-expect-error path doesn't start with "/" + endpoint("GET todos"); + // @ts-expect-error no method + endpoint("/todos"); + // @ts-expect-error empty + endpoint(""); + }); + + it("rejects strings that aren't literals", () => { + const route: string = "GET /todos"; + // @ts-expect-error the route must be known statically + endpoint(route); + }); + + it("keeps params of template literal segments", () => { + const orgId: string = "acme"; + const getUser = endpoint(`GET /orgs/${orgId}/users/:id`); + getUser({ params: { id: 1 } }); + // @ts-expect-error id is still required + getUser({}); + }); + + it("has helper types", () => { + expectTypeOf>().toEqualTypeOf<"PATCH">(); + expectTypeOf>().toEqualTypeOf<"/a/:b">(); + expectTypeOf>().toEqualTypeOf<"GET /a">(); + expectTypeOf>().toEqualTypeOf< + | "GET /" + | "POST /" + | "PUT /" + | "DELETE /" + | "PATCH /" + | "HEAD /" + | "OPTIONS /" + | "TRACE /" + | "QUERY /" + >(); + expectTypeOf<"GET /a">().toExtend(); + expectTypeOf<"GET a">().not.toExtend(); + }); +}); + +describe("params", () => { + const getTodo = endpoint("GET /todos/:id", { output: Todo }); + const listTodos = endpoint("GET /todos", { output: Schema.Array(Todo) }); + + it("are required when the path has params", () => { + getTodo({ params: { id: 1 } }); + getTodo({ params: { id: "abc" } }); + // @ts-expect-error missing options + getTodo(); + // @ts-expect-error missing params + getTodo({}); + // @ts-expect-error unknown param + getTodo({ params: { id: 1, ID: 2 } }); + // @ts-expect-error booleans aren't path values + getTodo({ params: { id: true } }); + }); + + it("are forbidden when the path has none", () => { + listTodos(); + listTodos({}); + // @ts-expect-error the path has no params + listTodos({ params: { id: 1 } }); + }); +}); + +describe("body", () => { + it("is forbidden on methods without a body, as is input", () => { + const list = endpoint("GET /todos"); + list({ body: undefined }); + // @ts-expect-error GET has no body + list({ body: {} }); + // @ts-expect-error GET has no input + endpoint("GET /todos", { input: NewTodo }); + // @ts-expect-error HEAD has no input + endpoint("HEAD /todos", { input: NewTodo }); + // @ts-expect-error OPTIONS has no body + endpoint("OPTIONS /todos")({ body: "x" }); + // @ts-expect-error TRACE has no body + endpoint("TRACE /todos")({ body: "x" }); + }); + + it("is required and typed with an input schema", () => { + const create = endpoint("POST /todos", { input: NewTodo, output: Todo }); + create({ body: { title: "x", due: new Date() } }); + // @ts-expect-error body is required + create(); + // @ts-expect-error body is required + create({}); + // @ts-expect-error due must be a Date + create({ body: { title: "x", due: "tomorrow" } }); + }); + + it("is optional and untyped without an input schema", () => { + const upload = endpoint("POST /upload"); + upload(); + upload({ body: new FormData() }); + upload({ body: { anything: true } }); + }); + + it("works with params", () => { + const replace = endpoint("PUT /todos/:id", { input: Todo, output: Todo }); + replace({ params: { id: 1 }, body: { id: 1, title: "x" } }); + // @ts-expect-error body is required + replace({ params: { id: 1 } }); + // @ts-expect-error params are required + replace({ body: { id: 1, title: "x" } }); + }); + + it("is required by EndpointArgs only with params or input", () => { + expectTypeOf>().toEqualTypeOf< + [options?: 1] + >(); + expectTypeOf>().toEqualTypeOf< + [options: 1] + >(); + expectTypeOf>().toEqualTypeOf< + [options: 1] + >(); + expectTypeOf>().toEqualTypeOf< + [options?: 1] + >(); + }); +}); + +describe("call options", () => { + const list = endpoint("GET /todos", { output: Schema.Array(Todo) }); + + it("accept query and headers", () => { + list({ query: { page: 1, tags: ["a", "b"], q: undefined } }); + list({ headers: { "x-trace": "1" } }); + // @ts-expect-error a Date isn't a query value + list({ query: { when: new Date() } }); + }); + + it("accept a query typed with an interface", () => { + interface ListParams { + readonly limit: number; + readonly after?: string | undefined; + } + const query: ListParams = { limit: 5 }; + list({ query }); + }); + + it("can't change the schemas", () => { + // @ts-expect-error output is fixed by the endpoint + list({ output: Todo }); + // @ts-expect-error error is fixed by the endpoint + list({ error: ApiError }); + // @ts-expect-error input is fixed by the endpoint + endpoint("POST /todos")({ input: NewTodo }); + }); +}); + +describe("plugin overrides", () => { + it("are typed from the client's plugins, in definitions and calls", () => { + const login = endpoint("POST /login", { plugins: { bearer: false } }); + login({ plugins: { retry: { times: 0 }, timeout: { duration: "1s" } } }); + // @ts-expect-error unknown plugin name + endpoint("POST /login", { plugins: { logger: false } }); + // @ts-expect-error bearer only accepts false + endpoint("POST /login", { plugins: { bearer: {} } }); + // @ts-expect-error unknown plugin name + login({ plugins: { logger: false } }); + // @ts-expect-error times is a number + login({ plugins: { retry: { times: "0" } } }); + }); +}); + +describe("return type", () => { + it("is the raw response without output", () => { + expectTypeOf( + endpoint("DELETE /todos/:id")({ params: { id: 1 } }), + ).toEqualTypeOf< + Effect.Effect< + Response, + NetworkError | HttpError.With | TimeoutError, + never + > + >(); + }); + + it("is the decoded output, with DecodeError", () => { + const getTodo = endpoint("GET /todos/:id", { output: Todo }); + expectTypeOf(getTodo({ params: { id: 1 } })).toEqualTypeOf< + Effect.Effect< + Todo, + NetworkError | HttpError.With | DecodeError | TimeoutError, + never + > + >(); + }); + + it("types HttpError.body with the endpoint's error schema", () => { + const call = endpoint("GET /todos", { error: ApiError })(); + type E = Effect.Error; + expectTypeOf["body"]>().toEqualTypeOf< + { readonly code: string } | undefined + >(); + }); + + it("uses the client's error schema by default", () => { + const withDefault = null as unknown as EndpointMethod< + [], + typeof ApiError + >; + const call = withDefault("GET /todos")(); + type E = Effect.Error; + expectTypeOf["body"]>().toEqualTypeOf< + { readonly code: string } | undefined + >(); + }); + + it("names the endpoint's type", () => { + const getTodo = endpoint("GET /todos/:id", { output: Todo }); + expectTypeOf(getTodo).toEqualTypeOf< + Endpoint<"GET /todos/:id", typeof Todo, Schema.Unknown, undefined, Ps> + >(); + }); +}); + +describe("services", () => { + class Locale extends Context.Service()( + "Locale", + ) {} + const Localized = Schema.String.pipe( + Schema.decode({ + decode: SchemaGetter.transformEffect((s: string) => + Effect.map(Effect.service(Locale), (l) => `${l.tag}:${s}`), + ), + encode: SchemaGetter.passthrough(), + }), + ); + const LocalizedInput = Schema.String.pipe( + Schema.decode({ + decode: SchemaGetter.passthrough(), + encode: SchemaGetter.transformEffect((s: string) => + Effect.map(Effect.service(Locale), (l) => `${l.tag}:${s}`), + ), + }), + ); + + it("are never for plain schemas", () => { + const call = endpoint("POST /todos", { input: NewTodo, output: Todo })({ + body: { title: "x", due: new Date() }, + }); + expectTypeOf>().toBeNever(); + }); + + it("include the schemas' services", () => { + const output = endpoint("GET /x", { output: Localized })(); + expectTypeOf>().toEqualTypeOf(); + const error = endpoint("GET /x", { error: Localized })(); + expectTypeOf>().toEqualTypeOf(); + const input = endpoint("POST /x", { input: LocalizedInput })({ + body: "x", + }); + expectTypeOf>().toEqualTypeOf(); + }); +}); + +describe("on a client", () => { + it("is available with the client's plugins and default error", () => { + const getTodo = (api: Client) => + api.endpoint("GET /todos/:id", { + output: Todo, + plugins: { retry: { times: 1 } }, + }); + type E = Effect.Error>>; + expectTypeOf["body"]>().toEqualTypeOf< + { readonly code: string } | undefined + >(); + expectTypeOf< + Extract + >().toEqualTypeOf(); + }); +}); diff --git a/test/types/plugin.test-d.ts b/test/types/plugin.test-d.ts index 4bf88a6..b22eeb8 100644 --- a/test/types/plugin.test-d.ts +++ b/test/types/plugin.test-d.ts @@ -124,6 +124,28 @@ describe("plugin()", () => { expectTypeOf>().toBeNever(); }); + it("gives hooks and wrap the call's route", () => { + const p = plugin({ + name: "routes", + onRequest: (request, ctx) => { + expectTypeOf(ctx.route).toEqualTypeOf(); + return Effect.succeed(request); + }, + onError: (_error, ctx) => { + expectTypeOf(ctx.route).toEqualTypeOf(); + return Effect.void; + }, + wrap: (client, _options, route) => { + expectTypeOf(route).toEqualTypeOf< + Effect.Effect + >(); + return client; + }, + }); + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + }); + it("rejects hooks returning the wrong type", () => { plugin({ name: "bad", From 86a34e3020030521ad2ca262580e4f96d47a494d Mon Sep 17 00:00:00 2001 From: monarcode Date: Tue, 13 Oct 2026 10:10:59 +0100 Subject: [PATCH 3/6] docs: add endpoint guides and a feature-sliced example app - expand the README Endpoints section with why, when to use them, and how to organize them - add `examples/app/`, a feature-sliced app with shared clients and module-owned endpoints, plus `examples/README.md` - rename `make` to `makeApi` in the examples and docs --- CHANGELOG.md | 10 + README.md | 273 ++++++++++++++++-- examples/README.md | 101 +++++++ .../app/infrastructure/http/app-api-client.ts | 33 +++ examples/app/infrastructure/http/plugins.ts | 23 ++ .../infrastructure/http/wallet-api-client.ts | 38 +++ examples/app/main.ts | 45 +++ examples/app/modules/todos/contract/todos.ts | 27 ++ examples/app/modules/todos/data/todo.api.ts | 32 ++ examples/app/modules/todos/public.ts | 3 + examples/app/modules/todos/todo.layer.ts | 6 + .../modules/wallet/application/send-money.ts | 26 ++ .../app/modules/wallet/contract/wallet.ts | 17 ++ .../app/modules/wallet/data/wallet.api.ts | 28 ++ examples/app/modules/wallet/public.ts | 4 + examples/app/modules/wallet/wallet.layer.ts | 6 + examples/app/runtime.ts | 21 ++ examples/backends.ts | 23 +- examples/basic.ts | 28 +- examples/builtins.ts | 27 +- examples/endpoints.ts | 27 +- examples/plugins.ts | 31 +- src/Client.ts | 12 +- src/plugins/bearer.ts | 2 +- src/plugins/logger.ts | 2 +- src/plugins/retry.ts | 2 +- src/plugins/timeout.ts | 2 +- 27 files changed, 739 insertions(+), 110 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/app/infrastructure/http/app-api-client.ts create mode 100644 examples/app/infrastructure/http/plugins.ts create mode 100644 examples/app/infrastructure/http/wallet-api-client.ts create mode 100644 examples/app/main.ts create mode 100644 examples/app/modules/todos/contract/todos.ts create mode 100644 examples/app/modules/todos/data/todo.api.ts create mode 100644 examples/app/modules/todos/public.ts create mode 100644 examples/app/modules/todos/todo.layer.ts create mode 100644 examples/app/modules/wallet/application/send-money.ts create mode 100644 examples/app/modules/wallet/contract/wallet.ts create mode 100644 examples/app/modules/wallet/data/wallet.api.ts create mode 100644 examples/app/modules/wallet/public.ts create mode 100644 examples/app/modules/wallet/wallet.layer.ts create mode 100644 examples/app/runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bd9a313..b901214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,16 @@ All notable changes to `@monarcode/effetch`, newest first. - `CallServices` and `CallEffect` accept `undefined` for the `input` schema `I`, as they already did for `O`. +### Documentation + +- The README's Endpoints section explains why endpoints exist, when to use + one rather than a method, and how to organize them: in one service for a + small app, in each module for a feature-sliced app, and with several + backends. +- New examples: `endpoints.ts`, `backends.ts`, and `app/`, a feature-sliced + app with shared clients and module-owned endpoints. `examples/README.md` + says what each example shows. + ## 0.1.0-alpha.2 ### Changed diff --git a/README.md b/README.md index 0ba69cf..61ecf7d 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ const ApiError = Schema.Struct({ code: Schema.String, message: Schema.String }); const session: { accessToken?: string } = {}; -const make = Fetch.make({ +const makeApi = Fetch.make({ baseUrl: Config.String("API_URL"), headers: { "x-client": "web" }, defaultError: ApiError, @@ -153,11 +153,11 @@ const make = Fetch.make({ ], }); -export class Api extends Context.Service>()( +export class Api extends Context.Service>()( "Api", ) {} -export const ApiLive = Layer.effect(Api)(make).pipe( +export const ApiLive = Layer.effect(Api)(makeApi).pipe( Layer.provide(FetchHttpClient.layer), ); ``` @@ -273,7 +273,7 @@ const Credentials = Schema.Struct({ }); const Session = Schema.Struct({ token: Schema.String }); -const make = Effect.gen(function* () { +const makeApi = Effect.gen(function* () { const api = yield* Fetch.make({ baseUrl: "https://api.example.com", plugins: [bearer({ token: () => readToken() })], @@ -290,7 +290,7 @@ const make = Effect.gen(function* () { }; }); -export class Api extends Context.Service>()( +export class Api extends Context.Service>()( "Api", ) {} @@ -331,32 +331,249 @@ or an absolute `http(s)://` URL. It's checked as you type: `"GTE /users"` and cache keys. Plugins see the same route as [`ctx.route`](#writing-a-plugin). -Endpoints are defined where there's a client, usually in the service that -holds it, as above. To split them across files, write functions that take the -client: +### Why endpoints + +An HTTP call mixes two kinds of information: + +| Fixed by the API | Different on every call | +| ---------------------------------------------------- | ----------------------------------- | +| the route; the response, error and body schemas | path params, query values, the body | +| rules for that route, such as "login sends no token" | a one-off header or plugin override | + +With the methods, both are written at every call site. Three calls to +`GET /users/:id` repeat the path and the `User` schema three times, and a +mistake in one of them, such as the wrong schema or a forgotten +`bearer: false`, still compiles. An endpoint takes the first column once, +when it's defined, and only the second column when it's called. + +That gives you: + +- **One place per route.** A path or a schema changes in one line, and the + service that holds the endpoints lists the API you use. +- **Stricter types than a method can have.** A method only learns its HTTP + method and its `input` at the call, so it has to accept both ways. An + endpoint knows them up front: `body` is required when there's an `input` + (it's optional with `api.post`), a `GET` endpoint rejects a `body` at + compile time (`api.request("GET", …)` only dies at runtime), and the route + is checked as you type. +- **Defaults that can't be forgotten.** An endpoint's headers and plugin + overrides apply to every call. +- **A name and a route.** Call sites read `users.get(…)` rather than a URL, + and `route`, `method` and `path` are there for query keys and log labels. + +Endpoints add no new behaviour: a call goes through the same pipeline as the +method it stands for (plugins, retries, decoding, errors), so the rest of this +README applies to both. A call needs nothing from the environment apart from +its schemas' services, so its Effect runs anywhere: in a service, in a query +function, or with `Effect.runPromise`. + +An endpoint is a saved call, not a contract shared with a server: for that, +use Effect's `HttpApi`. + +### Endpoints or methods + +Use an endpoint when a call: + +- is made from more than one place; +- has rules every call must follow, such as an auth exception or a content + type; +- sends a body with an `input` schema; +- belongs to a backend whose API you want listed in one place. + +Use a method for one-off calls, and for calls whose method or path is only +known at runtime; use `client.httpClient` for streaming. Mixing them is +normal: keep the client next to its endpoints, for the calls that aren't worth +one. + +Avoid: + +- **`api.endpoint(…)(…)` at a call site.** It's a method call written twice: + use the method. +- **Defining endpoints in code that runs on every request.** Define them once, + when the layer that holds them is built. +- **Typing a client parameter as `Fetch.Client`.** That's a client without + plugins, and yours won't fit. Use `Effect.Success`, where + `makeApi` is your `Fetch.make(…)`: the type of your own client. +- **Spreading several groups of endpoints into one object.** When two use the + same name, the later one silently wins. Nest them: `{ users, todos }`. + +### Organizing endpoints + +Endpoints are made from a client, so they're defined where a client is +available: in a layer that reads it, or in a function that takes it. Where +that is depends on the size of the app. + +#### A small app: one service + +Keep the client in one file and each area's endpoints in another, as a +function of the client. The service makes the client once and calls each +function once: ```ts -import type * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Fetch from "@monarcode/effetch"; +import { bearer } from "@monarcode/effetch/plugins"; const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }); const NewUser = Schema.Struct({ name: Schema.String }); +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); // client.ts -export const makeClient = Fetch.make({ baseUrl: "https://api.example.com" }); +export const makeClient = Fetch.make({ + baseUrl: "https://api.example.com", + plugins: [bearer({ token: "token" })], +}); export type Client = Effect.Success; // users.ts export const users = (api: Client) => ({ get: api.endpoint("GET /users/:id", { output: User }), create: api.endpoint("POST /users", { input: NewUser, output: User }), + login: api.endpoint("POST /auth/login", { + output: User, + plugins: { bearer: false }, + }), +}); + +// todos.ts +export const todos = (api: Client) => ({ + list: api.endpoint("GET /todos", { output: Schema.Array(Todo) }), +}); + +// api.ts +const makeApi = Effect.map(makeClient, (client) => ({ + client, // for one-off calls + users: users(client), + todos: todos(client), +})); + +export class Api extends Context.Service>()( + "Api", +) {} + +// anywhere +export const program = Effect.gen(function* () { + const { users } = yield* Api; + return yield* users.get({ params: { id: 1 } }); }); ``` -An endpoint is a saved call, not a contract shared with a server: for that, -use Effect's `HttpApi`. +`Client` is the type of your client, plugins and `defaultError` included, so +the plugin overrides in `users.ts` are checked against your plugins, and +errors are typed with your `defaultError`. + +#### A feature-sliced app: endpoints in each module + +When the app is split into feature modules, each with its own schemas, turn +it around: the client is shared infrastructure that knows nothing about the +modules, and each module defines its endpoints from it, next to its schemas. + +```text +src/ +├── infrastructure/http/ +│ └── app-api-client.ts the client: base URL, error body, plugins +├── modules/ +│ ├── todos/ +│ │ ├── contract/ schemas +│ │ ├── data/ endpoints, made from AppApiClient +│ │ ├── application/ workflows that take more than one call +│ │ └── public.ts +│ └── wallet/ +└── runtime.ts modules, on top of clients, on top of HttpClient +``` + +This layout is a personal preference, the one effetch's author uses in their +own apps, and effetch doesn't depend on it: rename, merge or split the folders +to suit your project. What carries over is that modules import the client, +never the other way around, and that endpoints are defined once, in a layer. + +```ts +import * as Config from "effect/Config"; +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 Schema from "effect/Schema"; + +import * as Fetch from "@monarcode/effetch"; + +// infrastructure/http/app-api-client.ts: how to reach the backend, nothing else +const makeAppApiClient = Fetch.make({ baseUrl: Config.String("APP_API_URL") }); + +export class AppApiClient extends Context.Service< + AppApiClient, + Effect.Success +>()("AppApiClient") {} + +export const AppApiClientLive = Layer.effect(AppApiClient)(makeAppApiClient); + +// modules/todos/contract/todos.ts +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); +const NewTodo = Schema.Struct({ title: Schema.String }); + +// modules/todos/data/todo.api.ts: the module's endpoints, as its service +const makeTodoApi = AppApiClient.useSync((client) => ({ + list: client.endpoint("GET /todos", { output: Schema.Array(Todo) }), + get: client.endpoint("GET /todos/:id", { output: Todo }), + create: client.endpoint("POST /todos", { input: NewTodo, output: Todo }), +})); + +export class TodoApi extends Context.Service< + TodoApi, + Effect.Success +>()("TodoApi") {} + +export const TodoApiLive = Layer.effect(TodoApi)(makeTodoApi); + +// runtime.ts +export const AppLayer = TodoApiLive.pipe( + Layer.provideMerge(AppApiClientLive), + Layer.provide(FetchHttpClient.layer), +); + +// anywhere +export const program = Effect.gen(function* () { + const todos = yield* TodoApi; + const todo = yield* todos.create({ body: { title: "Write docs" } }); + return yield* todos.get({ params: { id: todo.id } }); +}); +``` + +The client file stays the same size however many endpoints the app has, and +it never imports a module: dependencies point from the modules to the +infrastructure. A module with 20 endpoints has 20 one-line definitions in its +own `data/` folder, beside its own schemas. When they serve separate concerns, +split them into several services, each in its own file. + +Every module's data service is its endpoints, as `TodoApi` is here: one line +per operation, and every type inferred. An operation that isn't one HTTP call +(it combines calls, reshapes a response or adds a domain error) is a workflow +over those endpoints, in the module's `application/` folder, so every +module's `data/` folder looks the same. Declare a service's interface by hand +only when the module needs other implementations, such as an offline one or a +fake for UI work, and implement it with the endpoints. + +[`examples/app`](examples/app) is a complete app in this layout, with two +modules and two backends. + +#### Several backends + +Make one client per backend, each in its own service, with its own `baseUrl`, +`defaultError` and plugins. Each module, or each area's function, uses the +client of the backend it talks to, and one `HttpClient` layer serves them all. + +- Plugins are values: one `bearer` can serve several clients. +- `ctx.route` has no host. A plugin that groups by route and is shared by + several clients should take the backend's name, so each client gets its own + instance. +- Catch a backend's `HttpError` next to its calls, where `body` has that + backend's error shape. + +[`examples/backends.ts`](examples/backends.ts) does this in one file, and +[`examples/app`](examples/app) across modules. ## Errors @@ -388,13 +605,14 @@ const ApiError = Schema.Struct({ code: Schema.String }); export const todo = (id: number) => api.get("/todos/:id", { params: { id }, output: Todo, error: ApiError }).pipe( - Effect.catchTag("HttpError", (error) => - error.status === 404 - ? Effect.succeed(undefined) - : Effect.fail(error.body?.code ?? `HTTP ${error.status}`), - ), - Effect.catchTag("NetworkError", () => Effect.fail("You're offline")), - Effect.catchTag("DecodeError", (error) => Effect.fail(error.message)), + Effect.catchTags({ + HttpError: (error) => + error.status === 404 + ? Effect.succeed(undefined) + : Effect.fail(error.body?.code ?? `HTTP ${error.status}`), + NetworkError: () => Effect.fail("You're offline"), + DecodeError: (error) => Effect.fail(error.message), + }), ); ``` @@ -419,7 +637,7 @@ import { bearer, logger, retry, timeout } from "@monarcode/effetch/plugins"; declare const readToken: () => Promise; -export const make = Fetch.make({ +export const makeApi = Fetch.make({ baseUrl: "https://api.example.com", plugins: [ logger({ level: "Info" }), @@ -569,8 +787,8 @@ export const auth = Fetch.plugin({ }), }); -// make now needs Session; calls fail with SessionExpired too. -export const make = Fetch.make({ +// makeApi now needs Session; calls fail with SessionExpired too. +export const makeApi = Fetch.make({ baseUrl: "https://api.example.com", plugins: [auth], }); @@ -614,7 +832,8 @@ export const deadline = Fetch.plugin({ `Fetch.Plugin.Any[]` erases the plugins' types. [`examples/`](examples) has complete programs with built-in and custom -plugins, with endpoints, and with several backends, each with its own client. +plugins, endpoints, several backends and a feature-sliced app: its +[README](examples/README.md) says what each one shows. ## Testing @@ -673,14 +892,16 @@ import * as Layer from "effect/Layer"; import * as Fetch from "@monarcode/effetch"; -const make = Fetch.make({ baseUrl: "https://api.example.com" }); +const makeApi = Fetch.make({ baseUrl: "https://api.example.com" }); -export class Api extends Context.Service>()( +export class Api extends Context.Service>()( "Api", ) {} export const ApiLive = Layer.effect(Api)( - make.pipe(Effect.provideService(HttpClient.TracerPropagationEnabled, false)), + makeApi.pipe( + Effect.provideService(HttpClient.TracerPropagationEnabled, false), + ), ).pipe(Layer.provide(FetchHttpClient.layer)); ``` diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b4f303b --- /dev/null +++ b/examples/README.md @@ -0,0 +1,101 @@ +# Examples + +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. | + +`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. + +Throughout, the Effect that builds a client or a service is named after what +it builds: `makeApi` builds `Api`, `makeAppApiClient` builds `AppApiClient`, +`makeTodoApi` builds `TodoApi`. + +## `app/`: endpoints in a feature-sliced app + +> **This folder structure is a personal preference:** the layout effetch's +> author uses in their own apps (React Native, with Expo). effetch doesn't +> depend on any of it. Rename the folders and files, merge or split them, and +> adapt it to your own taste and project. What carries over to any layout is +> in [the rules it follows](#the-rules-it-follows). + +```text +app/ +├── infrastructure/ +│ └── http/ +│ ├── plugins.ts shared plugins: auth, error reporting +│ ├── 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: Todo, NewTodo, … +│ │ ├── data/ +│ │ │ └── todo.api.ts TodoApi: the module's endpoints +│ │ ├── todo.layer.ts the module's live layers +│ │ └── public.ts what other code may import +│ └── wallet/ +│ ├── contract/ +│ │ └── wallet.ts schemas: Balance, Transfer, Receipt +│ ├── data/ +│ │ └── wallet.api.ts WalletApi: the module's endpoints +│ ├── application/ +│ │ └── send-money.ts a workflow over WalletApi +│ ├── wallet.layer.ts +│ └── public.ts +├── runtime.ts composition root: modules, clients, HttpClient +└── main.ts code using the modules +``` + +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. +2. **A module defines its endpoints from the client it talks to,** in its + `data/` folder, importing the schemas from its own `contract/` folder. + Dependencies point one way: module → infrastructure, never back. +3. **Endpoints are defined 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`, + `WalletApi`): one line per operation, and every type inferred, so every + `data/` folder looks the same. +5. **An operation that isn't one HTTP call is a workflow in `application/`.** + `sendMoney` reads the balance, fails with `InsufficientFunds` when it's too + low, then sends the transfer. The endpoints stay plain; the rules live in + the workflow. +6. **The runtime composes the module layers on top of the client layers, on + top of one `HttpClient`.** A new module is one line in `runtime.ts`; a new + backend is one client file and one line. +7. **Other code imports a module through its `public.ts`,** and never sees a + route, a schema it doesn't need or a client. + +### As modules grow + +- A module with 20 or more endpoints still has one line per endpoint, in its + own `data/` folder, beside its own schemas. Nothing outside the module grows. +- When one module's endpoints serve separate concerns, split them into + several services, each in its own file (`wallet.api.ts` and + `transaction.api.ts`), and merge their layers in the module's + `.layer.ts`. +- When a module needs another implementation of its data service, such as an + offline one or a fake for UI work, declare the service's interface by hand + and implement it with the endpoints, in place of `.api.ts`. +- When a capability gets its own screens, consumers and API, make it a module + of its own. + +### With one backend + +Drop `wallet-api-client.ts`: every module uses `AppApiClient`. The layout is +otherwise the same. diff --git a/examples/app/infrastructure/http/app-api-client.ts b/examples/app/infrastructure/http/app-api-client.ts new file mode 100644 index 0000000..7adacbd --- /dev/null +++ b/examples/app/infrastructure/http/app-api-client.ts @@ -0,0 +1,33 @@ +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +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"; + +// 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. + +/** The body of this backend's non-2xx responses. */ +export const AppApiError = Schema.Struct({ + code: Schema.String, + message: Schema.String, +}); + +const makeAppApiClient = Fetch.make({ + baseUrl: Config.String("APP_API_URL"), + defaultError: AppApiError, + plugins: [logger(), reportErrors("app"), retry(), auth], +}); + +export class AppApiClient extends Context.Service< + AppApiClient, + Effect.Success +>()("infrastructure/http/AppApiClient") {} + +export const AppApiClientLive = Layer.effect(AppApiClient)(makeAppApiClient); diff --git a/examples/app/infrastructure/http/plugins.ts b/examples/app/infrastructure/http/plugins.ts new file mode 100644 index 0000000..e36b4c5 --- /dev/null +++ b/examples/app/infrastructure/http/plugins.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; + +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. + +export const session: { accessToken: string | undefined } = { + accessToken: undefined, +}; + +export const auth = bearer({ token: () => session.accessToken }); + +// `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. +export const reportErrors = (backend: string) => + Fetch.plugin({ + name: "reportErrors", + onError: (error, ctx) => + Effect.logWarning(`${backend} ${ctx.route} failed`, error), + }); diff --git a/examples/app/infrastructure/http/wallet-api-client.ts b/examples/app/infrastructure/http/wallet-api-client.ts new file mode 100644 index 0000000..fbcd18f --- /dev/null +++ b/examples/app/infrastructure/http/wallet-api-client.ts @@ -0,0 +1,38 @@ +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +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"; + +// A second backend, with its own base URL, error body and plugins: one +// client per backend, each in its own service. + +/** The body of this backend's non-2xx responses. */ +export const WalletApiError = Schema.Struct({ + error: Schema.String, + reason: Schema.String, +}); + +const makeWalletApiClient = Fetch.make({ + baseUrl: Config.String("WALLET_API_URL"), + defaultError: WalletApiError, + plugins: [ + logger(), + reportErrors("wallet"), + timeout({ duration: "10 seconds" }), + auth, + ], +}); + +export class WalletApiClient extends Context.Service< + WalletApiClient, + Effect.Success +>()("infrastructure/http/WalletApiClient") {} + +export const WalletApiClientLive = + Layer.effect(WalletApiClient)(makeWalletApiClient); diff --git a/examples/app/main.ts b/examples/app/main.ts new file mode 100644 index 0000000..fb89879 --- /dev/null +++ b/examples/app/main.ts @@ -0,0 +1,45 @@ +import * as Effect from "effect/Effect"; + +import { AppApiClient } from "./infrastructure/http/app-api-client.js"; +import { TodoApi } from "./modules/todos/public.js"; +import { sendMoney } from "./modules/wallet/public.js"; +import { AppLayer } from "./runtime.js"; + +// Code outside the modules uses what their `public.ts` exports. It never sees +// a route, a schema it doesn't need or a client. + +const payRent = Effect.gen(function* () { + const todos = yield* TodoApi; + + const task = yield* todos.create({ + body: { title: "Pay rent", due: new Date() }, + }); + const receipt = yield* sendMoney("w_me", "w_landlord", 1200); + yield* todos.update({ params: { id: task.id }, body: { done: true } }); + + const { todos: open } = yield* todos.list({ query: { done: false } }); + return { receipt: receipt.id, open: open.length }; +}).pipe( + Effect.catchTags({ + InsufficientFunds: (error) => + Effect.fail(`Only ${error.balance} left, ${error.amount} needed`), + HttpError: (error) => + Effect.fail(`HTTP ${error.status} from ${error.url}`), + TimeoutError: (error) => + Effect.fail(`${error.url} didn't answer within ${error.duration}`), + NetworkError: () => Effect.fail("You appear to be offline"), + DecodeError: (error) => + Effect.fail(`Unexpected response: ${error.message}`), + }), +); + +// A one-off call, made with a client directly rather than an endpoint. +const ping = Effect.gen(function* () { + const client = yield* AppApiClient; + const response = yield* client.head("/health", { + plugins: { logger: false, retry: false }, + }); + return response.status; +}); + +export const main = Effect.all([payRent, ping]).pipe(Effect.provide(AppLayer)); diff --git a/examples/app/modules/todos/contract/todos.ts b/examples/app/modules/todos/contract/todos.ts new file mode 100644 index 0000000..bd8427c --- /dev/null +++ b/examples/app/modules/todos/contract/todos.ts @@ -0,0 +1,27 @@ +import * as Schema from "effect/Schema"; + +// The module's wire formats. Its endpoints import these; nothing outside the +// module needs to. + +export const Todo = Schema.Struct({ + id: Schema.Finite, + title: Schema.String, + done: Schema.Boolean, + due: Schema.Date, +}); +export type Todo = typeof Todo.Type; + +export const TodoPage = Schema.Struct({ + todos: Schema.Array(Todo), + total: Schema.Finite, +}); +export type TodoPage = typeof TodoPage.Type; + +export const NewTodo = Schema.Struct({ + title: Schema.String, + due: Schema.Date, +}); +export type NewTodo = typeof NewTodo.Type; + +export const TodoPatch = Schema.Struct({ done: Schema.Boolean }); +export type TodoPatch = typeof TodoPatch.Type; diff --git a/examples/app/modules/todos/data/todo.api.ts b/examples/app/modules/todos/data/todo.api.ts new file mode 100644 index 0000000..0cd0d51 --- /dev/null +++ b/examples/app/modules/todos/data/todo.api.ts @@ -0,0 +1,32 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { AppApiClient } from "../../../infrastructure/http/app-api-client.js"; +import { NewTodo, Todo, TodoPage, TodoPatch } from "../contract/todos.js"; + +// The module's endpoints are its data service. Each operation is one line, +// next to the schemas it uses, and every type (params, body, success and +// errors) is inferred: there's no interface to keep in step. +// +// `AppApiClient.useSync` reads the shared client once, when the layer is +// built. + +const makeTodoApi = AppApiClient.useSync((client) => ({ + list: client.endpoint("GET /todos", { output: TodoPage }), + get: client.endpoint("GET /todos/:id", { output: Todo }), + create: client.endpoint("POST /todos", { input: NewTodo, output: Todo }), + update: client.endpoint("PATCH /todos/:id", { + input: TodoPatch, + output: Todo, + headers: { "content-type": "application/merge-patch+json" }, + }), + remove: client.endpoint("DELETE /todos/:id"), +})); + +export class TodoApi extends Context.Service< + TodoApi, + Effect.Success +>()("modules/todos/TodoApi") {} + +export const TodoApiLive = Layer.effect(TodoApi)(makeTodoApi); diff --git a/examples/app/modules/todos/public.ts b/examples/app/modules/todos/public.ts new file mode 100644 index 0000000..7569f60 --- /dev/null +++ b/examples/app/modules/todos/public.ts @@ -0,0 +1,3 @@ +// What other code may use from the module. +export type { NewTodo, Todo, TodoPage, TodoPatch } from "./contract/todos.js"; +export { TodoApi } from "./data/todo.api.js"; diff --git a/examples/app/modules/todos/todo.layer.ts b/examples/app/modules/todos/todo.layer.ts new file mode 100644 index 0000000..688fb6f --- /dev/null +++ b/examples/app/modules/todos/todo.layer.ts @@ -0,0 +1,6 @@ +import * as Layer from "effect/Layer"; + +import { TodoApiLive } from "./data/todo.api.js"; + +/** Every live layer of the module, for the app's runtime. */ +export const TodoModuleLive = Layer.mergeAll(TodoApiLive); diff --git a/examples/app/modules/wallet/application/send-money.ts b/examples/app/modules/wallet/application/send-money.ts new file mode 100644 index 0000000..6e29f4f --- /dev/null +++ b/examples/app/modules/wallet/application/send-money.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { WalletApi } from "../data/wallet.api.js"; + +// A workflow: an operation that isn't one HTTP call. It reads the balance, +// fails with a domain error when it's too low, then sends the transfer. The +// endpoints stay plain; the rules live here. + +export class InsufficientFunds extends Schema.TaggedError()( + "InsufficientFunds", + { balance: Schema.Finite, amount: Schema.Finite }, +) {} + +export const sendMoney = Effect.fn("sendMoney")(function* ( + from: string, + to: string, + amount: number, +) { + const wallet = yield* WalletApi; + const balance = yield* wallet.balance({ params: { walletId: from } }); + if (balance.amount < amount) { + return yield* new InsufficientFunds({ balance: balance.amount, amount }); + } + return yield* wallet.transfer({ body: { from, to, amount } }); +}); diff --git a/examples/app/modules/wallet/contract/wallet.ts b/examples/app/modules/wallet/contract/wallet.ts new file mode 100644 index 0000000..c5ef2d3 --- /dev/null +++ b/examples/app/modules/wallet/contract/wallet.ts @@ -0,0 +1,17 @@ +import * as Schema from "effect/Schema"; + +export const Balance = Schema.Struct({ + amount: Schema.Finite, + currency: Schema.String, +}); +export type Balance = typeof Balance.Type; + +export const Transfer = Schema.Struct({ + from: Schema.String, + to: Schema.String, + amount: Schema.Finite, +}); +export type Transfer = typeof Transfer.Type; + +export const Receipt = Schema.Struct({ id: Schema.String }); +export type Receipt = typeof Receipt.Type; diff --git a/examples/app/modules/wallet/data/wallet.api.ts b/examples/app/modules/wallet/data/wallet.api.ts new file mode 100644 index 0000000..9685342 --- /dev/null +++ b/examples/app/modules/wallet/data/wallet.api.ts @@ -0,0 +1,28 @@ +import * as Context from "effect/Context"; +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"; + +// 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 +// more than one call are workflows, in `application/`. + +const makeWalletApi = WalletApiClient.useSync((client) => ({ + balance: client.endpoint("GET /wallets/:walletId/balance", { + output: Balance, + }), + transfer: client.endpoint("POST /transfers", { + input: Transfer, + output: Receipt, + plugins: { timeout: { duration: "30 seconds" } }, + }), +})); + +export class WalletApi extends Context.Service< + WalletApi, + Effect.Success +>()("modules/wallet/WalletApi") {} + +export const WalletApiLive = Layer.effect(WalletApi)(makeWalletApi); diff --git a/examples/app/modules/wallet/public.ts b/examples/app/modules/wallet/public.ts new file mode 100644 index 0000000..3d84802 --- /dev/null +++ b/examples/app/modules/wallet/public.ts @@ -0,0 +1,4 @@ +// 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 { WalletApi } from "./data/wallet.api.js"; diff --git a/examples/app/modules/wallet/wallet.layer.ts b/examples/app/modules/wallet/wallet.layer.ts new file mode 100644 index 0000000..cabe376 --- /dev/null +++ b/examples/app/modules/wallet/wallet.layer.ts @@ -0,0 +1,6 @@ +import * as Layer from "effect/Layer"; + +import { WalletApiLive } from "./data/wallet.api.js"; + +/** Every live layer of the module, for the app's runtime. */ +export const WalletModuleLive = Layer.mergeAll(WalletApiLive); diff --git a/examples/app/runtime.ts b/examples/app/runtime.ts new file mode 100644 index 0000000..a210c5d --- /dev/null +++ b/examples/app/runtime.ts @@ -0,0 +1,21 @@ +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as Layer from "effect/Layer"; + +import { AppApiClientLive } from "./infrastructure/http/app-api-client.js"; +import { WalletApiClientLive } from "./infrastructure/http/wallet-api-client.js"; +import { TodoModuleLive } from "./modules/todos/todo.layer.js"; +import { WalletModuleLive } from "./modules/wallet/wallet.layer.js"; + +// The composition root: every module's layer, on top of the clients they +// use, on top of one HttpClient. Adding a module adds one line here. + +const Clients = Layer.mergeAll(AppApiClientLive, WalletApiClientLive); + +const Modules = Layer.mergeAll(TodoModuleLive, WalletModuleLive); + +// `provideMerge` keeps the clients available too, for one-off calls that +// aren't worth an endpoint. +export const AppLayer = Modules.pipe( + Layer.provideMerge(Clients), + Layer.provide(FetchHttpClient.layer), +); diff --git a/examples/backends.ts b/examples/backends.ts index b04aeb1..5878f43 100644 --- a/examples/backends.ts +++ b/examples/backends.ts @@ -46,7 +46,7 @@ const reportErrors = (backend: string) => Effect.logWarning(`${backend} ${ctx.route} failed`, error), }); -const makeUsers = Effect.gen(function* () { +const makeUsersApi = Effect.gen(function* () { const client = yield* Fetch.make({ baseUrl: Config.String("USERS_API_URL"), defaultError: UsersError, @@ -62,10 +62,10 @@ const makeUsers = Effect.gen(function* () { export class UsersApi extends Context.Service< UsersApi, - Effect.Success + Effect.Success >()("UsersApi") {} -const makeWallet = Effect.gen(function* () { +const makeWalletApi = Effect.gen(function* () { const client = yield* Fetch.make({ baseUrl: Config.String("WALLET_API_URL"), defaultError: WalletError, @@ -91,13 +91,13 @@ const makeWallet = Effect.gen(function* () { export class WalletApi extends Context.Service< WalletApi, - Effect.Success + Effect.Success >()("WalletApi") {} // One HttpClient for both. export const ApisLive = Layer.mergeAll( - Layer.effect(UsersApi)(makeUsers), - Layer.effect(WalletApi)(makeWallet), + Layer.effect(UsersApi)(makeUsersApi), + Layer.effect(WalletApi)(makeWalletApi), ).pipe(Layer.provide(FetchHttpClient.layer)); // Each backend's errors are caught next to its calls, where `HttpError.body` @@ -144,12 +144,11 @@ export const program = (to: string) => yield* recordActivity(user.id, receipt.id); return receipt; }).pipe( - Effect.catchTag("NetworkError", () => - Effect.fail("You appear to be offline"), - ), - Effect.catchTag("DecodeError", (error) => - Effect.fail(`Unexpected response: ${error.message}`), - ), + Effect.catchTags({ + NetworkError: () => Effect.fail("You appear to be offline"), + DecodeError: (error) => + Effect.fail(`Unexpected response: ${error.message}`), + }), ); export const main = (to: string) => program(to).pipe(Effect.provide(ApisLive)); diff --git a/examples/basic.ts b/examples/basic.ts index 266aec1..ecd391d 100644 --- a/examples/basic.ts +++ b/examples/basic.ts @@ -16,15 +16,17 @@ const NewTodo = Schema.Struct({ title: Schema.String, due: Schema.Date }); const ApiError = Schema.Struct({ code: Schema.String, message: Schema.String }); -const make = Fetch.make({ +const makeApi = Fetch.make({ baseUrl: "https://api.example.com", headers: { accept: "application/json" }, defaultError: ApiError, }); -class Api extends Context.Service>()("Api") {} +class Api extends Context.Service>()( + "Api", +) {} -export const ApiLive = Layer.effect(Api)(make).pipe( +export const ApiLive = Layer.effect(Api)(makeApi).pipe( Layer.provide(FetchHttpClient.layer), ); @@ -52,17 +54,15 @@ export const program = Effect.gen(function* () { return todos.length; }).pipe( - Effect.catchTag("HttpError", (error) => - error.status === 404 - ? Effect.succeed(0) - : Effect.fail(error.body?.message ?? `HTTP ${error.status}`), - ), - Effect.catchTag("NetworkError", () => - Effect.fail("You appear to be offline"), - ), - Effect.catchTag("DecodeError", (error) => - Effect.fail(`Unexpected response: ${error.message}`), - ), + Effect.catchTags({ + HttpError: (error) => + error.status === 404 + ? Effect.succeed(0) + : 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/examples/builtins.ts b/examples/builtins.ts index ae7eaae..f6534bb 100644 --- a/examples/builtins.ts +++ b/examples/builtins.ts @@ -21,7 +21,7 @@ const session: { accessToken: string | undefined } = { accessToken: undefined, }; -export const make = Fetch.make({ +export const makeApi = Fetch.make({ baseUrl: "https://api.example.com", defaultError: ApiError, plugins: [ @@ -32,11 +32,11 @@ export const make = Fetch.make({ ], }); -export class Api extends Context.Service>()( +export class Api extends Context.Service>()( "Api", ) {} -export const ApiLive = Layer.effect(Api)(make).pipe( +export const ApiLive = Layer.effect(Api)(makeApi).pipe( Layer.provide(FetchHttpClient.layer), ); @@ -83,18 +83,15 @@ export const program = (email: string, password: string) => const todos = yield* listTodos; return todos.filter((todo) => !todo.done).length; }).pipe( - Effect.catchTag("TimeoutError", (error) => - Effect.fail(`${error.url} didn't answer within ${error.duration}`), - ), - Effect.catchTag("HttpError", (error) => - Effect.fail(error.body?.message ?? `HTTP ${error.status}`), - ), - Effect.catchTag("NetworkError", () => - Effect.fail("You appear to be offline"), - ), - Effect.catchTag("DecodeError", (error) => - Effect.fail(`Unexpected response: ${error.message}`), - ), + Effect.catchTags({ + TimeoutError: (error) => + Effect.fail(`${error.url} didn't answer within ${error.duration}`), + 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 = (email: string, password: string) => diff --git a/examples/endpoints.ts b/examples/endpoints.ts index 5d35882..b3c8455 100644 --- a/examples/endpoints.ts +++ b/examples/endpoints.ts @@ -31,7 +31,7 @@ const session: { accessToken: string | undefined } = { // The endpoints are defined once, with the client, and the service exposes // them as its methods. -const make = Effect.gen(function* () { +const makeApi = Effect.gen(function* () { const api = yield* Fetch.make({ baseUrl: "https://api.example.com", defaultError: ApiError, @@ -63,11 +63,11 @@ const make = Effect.gen(function* () { }; }); -export class Api extends Context.Service>()( +export class Api extends Context.Service>()( "Api", ) {} -export const ApiLive = Layer.effect(Api)(make).pipe( +export const ApiLive = Layer.effect(Api)(makeApi).pipe( Layer.provide(FetchHttpClient.layer), ); @@ -100,18 +100,15 @@ export const program = (email: string, password: string) => yield* api.deleteTodo({ params: { id: created.id } }); return { open: open.length, all: all.length }; }).pipe( - Effect.catchTag("TimeoutError", (error) => - Effect.fail(`${error.url} didn't answer within ${error.duration}`), - ), - Effect.catchTag("HttpError", (error) => - Effect.fail(error.body?.message ?? `HTTP ${error.status}`), - ), - Effect.catchTag("NetworkError", () => - Effect.fail("You appear to be offline"), - ), - Effect.catchTag("DecodeError", (error) => - Effect.fail(`Unexpected response: ${error.message}`), - ), + Effect.catchTags({ + TimeoutError: (error) => + Effect.fail(`${error.url} didn't answer within ${error.duration}`), + 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 = (email: string, password: string) => diff --git a/examples/plugins.ts b/examples/plugins.ts index 8f763b8..2212e5a 100644 --- a/examples/plugins.ts +++ b/examples/plugins.ts @@ -127,17 +127,17 @@ const reportErrors = Fetch.plugin({ ), }); -export const make = Fetch.make({ +export const makeApi = Fetch.make({ baseUrl: "https://api.example.com", defaultError: ApiError, plugins: [reportErrors, deadline, apiVersion, auth], }); -export class Api extends Context.Service>()( +export class Api extends Context.Service>()( "Api", ) {} -export const ApiLive = Layer.effect(Api)(make).pipe( +export const ApiLive = Layer.effect(Api)(makeApi).pipe( Layer.provide(FetchHttpClient.layer), Layer.provideMerge(SessionLive), ); @@ -176,21 +176,16 @@ export const program = (email: string, password: string) => const todos = yield* listTodos; return todos.length; }).pipe( - Effect.catchTag("SessionExpired", () => - Effect.fail("Please sign in again"), - ), - Effect.catchTag("DeadlineExceeded", (error) => - Effect.fail(`${error.method} ${error.url} took too long`), - ), - Effect.catchTag("HttpError", (error) => - Effect.fail(error.body?.message ?? `HTTP ${error.status}`), - ), - Effect.catchTag("NetworkError", () => - Effect.fail("You appear to be offline"), - ), - Effect.catchTag("DecodeError", (error) => - Effect.fail(`Unexpected response: ${error.message}`), - ), + Effect.catchTags({ + SessionExpired: () => Effect.fail("Please sign in again"), + DeadlineExceeded: (error) => + Effect.fail(`${error.method} ${error.url} took too long`), + 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 = (email: string, password: string) => diff --git a/src/Client.ts b/src/Client.ts index 060e488..0525347 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -59,7 +59,7 @@ import type { Plugin } from "./Plugin.js"; * Effect.succeed(HttpClientRequest.setHeader(request, "x-api-key", "key")), * }); * - * const make = Fetch.make({ + * const makeApi = Fetch.make({ * baseUrl: Config.String("API_URL"), * headers: { accept: "application/json" }, * defaultError: ApiError, @@ -337,7 +337,7 @@ export interface Client< * const Credentials = Schema.Struct({ user: Schema.String, password: Schema.String }); * const Session = Schema.Struct({ token: Schema.String }); * - * const make = Effect.gen(function* () { + * const makeApi = Effect.gen(function* () { * const api = yield* Fetch.make({ * baseUrl: "https://api.example.com", * plugins: [bearer({ token: "token" })], @@ -353,7 +353,7 @@ export interface Client< * }; * }); * - * class Api extends Context.Service>()("Api") {} + * class Api extends Context.Service>()("Api") {} * * const program = Effect.gen(function* () { * const { getUser, createUser } = yield* Api; @@ -419,14 +419,14 @@ export interface Client< * import * as FetchHttpClient from "effect/http/FetchHttpClient"; * import * as Fetch from "@monarcode/effetch"; * - * const make = Fetch.make({ + * const makeApi = Fetch.make({ * baseUrl: "https://api.example.com", * headers: { accept: "application/json" }, * }); * - * class Api extends Context.Service>()("Api") {} + * class Api extends Context.Service>()("Api") {} * - * const ApiLive = Layer.effect(Api)(make).pipe( + * const ApiLive = Layer.effect(Api)(makeApi).pipe( * Layer.provide(FetchHttpClient.layer), * ); * ``` diff --git a/src/plugins/bearer.ts b/src/plugins/bearer.ts index 89e79cd..34147e7 100644 --- a/src/plugins/bearer.ts +++ b/src/plugins/bearer.ts @@ -142,7 +142,7 @@ export type BearerPlugin = Plugin< * }), * }); * - * const make = Fetch.make({ + * const makeApi = Fetch.make({ * baseUrl: "https://api.example.com", * plugins: [fromSession], * }); diff --git a/src/plugins/logger.ts b/src/plugins/logger.ts index fb30271..2621a3a 100644 --- a/src/plugins/logger.ts +++ b/src/plugins/logger.ts @@ -66,7 +66,7 @@ export type LoggerPlugin = Plugin<"logger">; * import * as Fetch from "@monarcode/effetch"; * import { logger } from "@monarcode/effetch/plugins"; * - * const make = Fetch.make({ + * const makeApi = Fetch.make({ * baseUrl: "https://api.example.com", * plugins: [logger({ level: "Info" })], * }); diff --git a/src/plugins/retry.ts b/src/plugins/retry.ts index eaf73de..255a8d5 100644 --- a/src/plugins/retry.ts +++ b/src/plugins/retry.ts @@ -99,7 +99,7 @@ class RetryableStatus extends Data.TaggedError("RetryableStatus")<{ * import * as Fetch from "@monarcode/effetch"; * import { retry } from "@monarcode/effetch/plugins"; * - * const make = Fetch.make({ + * const makeApi = Fetch.make({ * baseUrl: "https://api.example.com", * plugins: [retry({ times: 3 })], * }); diff --git a/src/plugins/timeout.ts b/src/plugins/timeout.ts index f0c9a45..3fd46cb 100644 --- a/src/plugins/timeout.ts +++ b/src/plugins/timeout.ts @@ -95,7 +95,7 @@ export type TimeoutPlugin = Plugin< * import * as Fetch from "@monarcode/effetch"; * import { timeout } from "@monarcode/effetch/plugins"; * - * const make = Fetch.make({ + * const makeApi = Fetch.make({ * baseUrl: "https://api.example.com", * plugins: [timeout({ duration: "10 seconds" })], * }); From 3c44f6527ed07b47dc7881d6ff2492339bb3150c Mon Sep 17 00:00:00 2001 From: monarcode Date: Tue, 13 Oct 2026 10:55:09 +0100 Subject: [PATCH 4/6] docs: reorganize README as a top-to-bottom guide - rewrite the intro, add a side-by-side with `HttpClient`, and restructure sections into a two-level table of contents - group content into getting started, clients, calls, errors, plugins, endpoints, structuring an app, testing and reference - note the reorganization in the changelog --- CHANGELOG.md | 4 + README.md | 1236 ++++++++++++++++++++++++++++++++------------------ 2 files changed, 805 insertions(+), 435 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b901214..e7ca429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,10 @@ All notable changes to `@monarcode/effetch`, newest first. ### Documentation +- The README is reorganized as a guide that reads top to bottom: why effetch + (with a side-by-side with `HttpClient`), getting started, then clients, + calls, errors, plugins and writing them, endpoints, structuring an app and + testing, with a two-level table of contents. - The README's Endpoints section explains why endpoints exist, when to use one rather than a method, and how to organize them: in one service for a small app, in each module for a feature-sliced app, and with several diff --git a/README.md b/README.md index 61ecf7d..5f935d8 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ [![npm](https://img.shields.io/npm/v/@monarcode/effetch)](https://www.npmjs.com/package/@monarcode/effetch) [![CI](https://github.com/monarcode/effetch/actions/workflows/ci.yml/badge.svg)](https://github.com/monarcode/effetch/actions/workflows/ci.yml) -Typed, schema-driven HTTP calls on [Effect](https://effect.website) v4's -`HttpClient`: path parameters checked against the path, object queries, -decoded responses, typed error bodies and plugins. +**Typed HTTP calls for [Effect](https://effect.website), without the +boilerplate.** Give effetch a path and a schema. It fills in the path, builds +the query, checks the status and decodes the response, and hands you back an +Effect that's typed end to end, errors included. ```ts import * as Schema from "effect/Schema"; @@ -27,53 +28,152 @@ const todo = api.get("/todos/:id", { params: { id: 1 }, output: Todo }); // NetworkError | HttpError | DecodeError> ``` -> **Alpha.** effetch is in early alpha (`0.1.0-alpha.N`). The API may change -> between releases. +> **Release candidate.** effetch is close to its first stable release +> (`0.1.0-rc.N`). The API may still change before 0.1.0. ## Contents -- [Why](#why) -- [Install](#install) -- [Quick start](#quick-start) -- [Making a client](#making-a-client) -- [Calling](#calling) -- [Endpoints](#endpoints) -- [Errors](#errors) +- [Why effetch](#why-effetch) + - [At a glance](#at-a-glance) + - [What you get](#what-you-get) + - [Where it fits](#where-it-fits) +- [Getting started](#getting-started) + - [Install](#install) + - [Your first call](#your-first-call) +- [Creating a client](#creating-a-client) + - [Configuring a client](#configuring-a-client) + - [Sharing it across your app](#sharing-it-across-your-app) +- [Making calls](#making-calls) + - [Methods](#methods) + - [Path and query parameters](#path-and-query-parameters) + - [Sending a body](#sending-a-body) + - [Reading the response](#reading-the-response) + - [All call options](#all-call-options) + - [When the methods aren't enough](#when-the-methods-arent-enough) +- [Handling errors](#handling-errors) + - [What can go wrong](#what-can-go-wrong) + - [Catching errors](#catching-errors) + - [Typed error bodies](#typed-error-bodies) + - [Safe to log](#safe-to-log) + - [Bugs are defects](#bugs-are-defects) - [Plugins](#plugins) + - [Built-in plugins](#built-in-plugins) + - [Plugin order](#plugin-order) + - [Changing plugins for one call](#changing-plugins-for-one-call) +- [Writing plugins](#writing-plugins) + - [Your first plugin](#your-first-plugin) + - [Hooks](#hooks) + - [Knowing the route](#knowing-the-route) + - [Example: signing out on a 401](#example-signing-out-on-a-401) + - [Example: a deadline with its own error](#example-a-deadline-with-its-own-error) + - [Tips](#tips) +- [Endpoints](#endpoints) + - [Defining endpoints](#defining-endpoints) + - [Calling an endpoint](#calling-an-endpoint) + - [Defaults and overrides](#defaults-and-overrides) + - [Why use endpoints](#why-use-endpoints) + - [Endpoints or methods?](#endpoints-or-methods) +- [Structuring an app](#structuring-an-app) + - [A small app](#a-small-app) + - [A feature-sliced app](#a-feature-sliced-app) + - [Several backends](#several-backends) - [Testing](#testing) -- [Browsers](#browsers) -- [React Native](#react-native) -- [Limitations](#limitations) -- [API](#api) +- [Platform notes](#platform-notes) + - [Browsers](#browsers) + - [React Native](#react-native) +- [Reference](#reference) + - [Limitations](#limitations) + - [API](#api) +- [Examples](#examples) - [Development](#development) +- [License](#license) + +## Why effetch + +Effect gives you two ways to call an HTTP API. `HttpClient` is low level: for +every request, you build the URL, check the status and decode the body +yourself. `HttpApi` is contract first: you describe the whole API up front and +share it with the server, which is ideal when you own both sides. + +Most apps sit somewhere in between. They call a backend they don't control, +such as their company's REST API or a third-party service, one call at a time. +effetch is made for that. It's a thin layer over `HttpClient` that gives each +call the types and error handling of a contract, without you having to write +one. -## Why +### At a glance + +Here's the same call written with `HttpClient` alone, and with effetch: + +```ts +import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as HttpIncomingMessage from "effect/http/HttpIncomingMessage"; +import * as Schema from "effect/Schema"; -Effect gives you two ways to call HTTP APIs: +import type * as Fetch from "@monarcode/effetch"; -- `HttpClient` is low level. You build each request, check the status and - decode the body yourself. -- `HttpApi` and `HttpApiClient` are contract first. You describe the whole - API up front and share it with the server, which is ideal when you own - both sides. +declare const api: Fetch.Client; -effetch sits in between, for calling backends you don't own, one call at a -time. You get: +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); -- path parameters inferred from the path: `"/users/:id"` requires - `params: { id }`; -- queries from plain objects, with arrays and `undefined` handled; -- responses decoded with a `Schema`, and error bodies decoded with another; -- three tagged errors to `catchTag` on, plus your plugins' own; -- Effect-native plugins for auth, retries, timeouts and logging, with typed - per-call overrides; -- endpoints: a route like `"GET /users/:id"` saved with its schemas, headers - and plugin overrides, then called like a function. +// With HttpClient alone +export const getTodoByHand = (id: number) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get( + `https://api.example.com/todos/${encodeURIComponent(id)}`, + ); + const ok = yield* HttpClientResponse.filterStatusOk(response); + return yield* HttpIncomingMessage.schemaBodyJson(Todo)(ok); + }); -It has no platform-specific code, and targets browsers, React Native, -Node 20+, Bun and Deno. +// With effetch +export const getTodo = (id: number) => + api.get("/todos/:id", { params: { id }, output: Todo }); +``` -## Install +Both send the same request and decode the same response. The effetch version +also: + +- checks at compile time that every call passes an `id`; +- fails with `NetworkError`, `HttpError` or `DecodeError` rather than + `HttpClientError | SchemaError`, so you can handle each case on its own; +- decodes the API's error body onto `HttpError`, with a schema you give it; +- runs the client's plugins, such as auth, retries, timeouts and logging, on + every call. + +### What you get + +- **Paths that check themselves.** Write `"/users/:id"` and TypeScript asks + for `params: { id }`. No more, no less. +- **Queries from plain objects.** `{ page: 2, tags: ["a", "b"] }` becomes + `?page=2&tags=a&tags=b`, and `undefined` values are left out. +- **Responses you can trust.** Decode the response with a `Schema`, and the + API's error body with another. The schemas' types become the call's types. +- **Errors you can catch by name.** Three tagged errors, `NetworkError`, + `HttpError` and `DecodeError`, plus any your plugins add, all in the type. +- **Plugins for the repetitive parts.** Auth, retries, timeouts and logging + are built in, and your own take a few lines. Any call can turn a plugin off + or adjust it, and TypeScript checks the names and options. +- **Endpoints.** Save a route like `"GET /users/:id"` with its schemas once, + then call it like a function wherever you need it. +- **Runs anywhere.** There's no platform-specific code: it targets browsers, + React Native, Node 20+, Bun and Deno. + +### Where it fits + +- Use **effetch** to call REST and JSON APIs that you don't own, or don't share + code with. +- Use Effect's **`HttpApi`** when you own both the server and the client, and + want them to share one contract. +- Use **`HttpClient`** directly for what effetch doesn't cover, such as + streaming. Every effetch client gives you its own when you need it. + +## Getting started + +### Install ```bash pnpm add @monarcode/effetch effect @@ -86,12 +186,17 @@ npx expo install effect pnpm add @monarcode/effetch ``` -- `effect` 4.x is a peer dependency. -- The package is ESM only. +You'll need: + +- `effect` 4.x, which is a peer dependency; +- an ESM project, since the package is ESM only; - TypeScript 5.4 or newer. With `skipLibCheck: false`, Effect's own declarations need TypeScript 5.7 or newer. -## Quick start +### Your first call + +Let's fetch a todo from [JSONPlaceholder](https://jsonplaceholder.typicode.com), +a free fake API: ```ts import * as Effect from "effect/Effect"; @@ -120,18 +225,35 @@ const program = Effect.gen(function* () { await Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer))); ``` -`Fetch.make` is an Effect that needs an `HttpClient`. Here -`FetchHttpClient.layer` provides one built on the global `fetch`. Any other -`HttpClient` works too, such as Node's or a mock in tests. +Step by step: + +1. **`Todo` describes the response.** effetch decodes the body with it, so + `todo` is typed, and checked when it arrives. +2. **`Fetch.make` creates a client** for one API. Everything its calls have in + common, such as the base URL, lives here. +3. **`api.get` makes the call.** The `:id` in the path is filled in from + `params`. +4. **`FetchHttpClient.layer` sends the requests.** effetch is built on + Effect's `HttpClient`, so you choose which one to use when you run your + program. This one uses the global `fetch`. Node's works too, and in tests + you'd use a fake one (see [Testing](#testing)). + +Calling an API from a web page on another origin? Read +[Browsers](#browsers) first. + +From here, the guide builds up one step at a time: clients, calls and errors +first, then plugins and endpoints, then structuring a whole app. -## Making a client +## Creating a client + +A client holds everything a backend's calls have in common: where the backend +lives, the headers every request needs, the shape of its errors and the +plugins every call goes through. + +### Configuring a client ```ts import * as Config from "effect/Config"; -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 Schema from "effect/Schema"; import * as Fetch from "@monarcode/effetch"; @@ -141,7 +263,7 @@ const ApiError = Schema.Struct({ code: Schema.String, message: Schema.String }); const session: { accessToken?: string } = {}; -const makeApi = Fetch.make({ +export const makeApi = Fetch.make({ baseUrl: Config.String("API_URL"), headers: { "x-client": "web" }, defaultError: ApiError, @@ -152,6 +274,34 @@ const makeApi = Fetch.make({ bearer({ token: () => session.accessToken }), ], }); +``` + +Every option is optional: + +| Option | What it's for | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUrl` | Where the API lives. It's put in front of every path that isn't a full `http(s)://` URL. Pass a string, or an Effect that's run once when the client is made, such as a `Config`. It can't contain a query string or a fragment. | +| `headers` | Headers sent with every request. A call's header with the same name wins. `content-type` and `content-length` are ignored here, since they come from each body. | +| `defaultError` | A schema for the body of the API's error responses (see [Typed error bodies](#typed-error-bodies)). | +| `plugins` | Behaviour every call goes through (see [Plugins](#plugins)). | + +### Sharing it across your app + +`Fetch.make` returns an Effect that builds the client. Build it once, and +share it as a service: + +```ts +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 Schema from "effect/Schema"; + +import * as Fetch from "@monarcode/effetch"; + +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); + +const makeApi = Fetch.make({ baseUrl: "https://api.example.com" }); export class Api extends Context.Service>()( "Api", @@ -160,23 +310,70 @@ export class Api extends Context.Service>()( export const ApiLive = Layer.effect(Api)(makeApi).pipe( Layer.provide(FetchHttpClient.layer), ); + +// Anywhere in your app +export const program = Effect.gen(function* () { + const api = yield* Api; + return yield* api.get("/todos/:id", { params: { id: 1 }, output: Todo }); +}); + +// Where you run it +export const runnable = program.pipe(Effect.provide(ApiLive)); +``` + +Two things worth knowing: + +- **Your client's type is `Effect.Success`.** It includes + your plugins and your `defaultError`. Use it wherever you need to type a + client, such as a function parameter. `Fetch.Client` is the type of a client + without plugins, and yours won't fit it. +- **Calls need nothing from the environment.** The client holds on to the + `HttpClient`, the base URL and any services its plugins need. Its calls only + need the services of their own schemas, if any, so they run anywhere: in a + service, in a query function, or with `Effect.runPromise`. + +## Making calls + +### Methods + +A client has a method for each HTTP method. Each takes a path and an object of +options, and returns an Effect. + +- `get`, `head` and `options` send no body. +- `post`, `put`, `patch` and `del` can send one. It's `del` because `delete` + is a reserved word. + +### Path and query parameters + +```ts +import * as Schema from "effect/Schema"; + +import type * as Fetch from "@monarcode/effetch"; + +declare const api: Fetch.Client; + +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); + +// GET /todos/1 +export const one = api.get("/todos/:id", { params: { id: 1 }, output: Todo }); + +// GET /todos?page=1&tags=work&tags=home +export const some = api.get("/todos", { + query: { page: 1, tags: ["work", "home"], search: undefined }, + output: Schema.Array(Todo), +}); ``` -| Option | Description | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseUrl` | Prepended to every path that isn't an absolute `http(s)://` URL. A string, or an Effect run once when the client is made (a `Config` works). It can't contain a query string or a fragment. | -| `headers` | Sent with every request. A call's header of the same name wins. `content-type` and `content-length` are ignored here: they come from each body. | -| `defaultError` | The schema for non-2xx response bodies of calls without their own `error` (see [Errors](#errors)). | -| `plugins` | See [Plugins](#plugins). The first one is the outermost. | +Every `:name` in a path is a parameter, filled in from `params`. When a path +has parameters, `params` is required, with exactly those names, and when it +has none, `params` isn't allowed. Values are URI-encoded for you. -The client holds on to the `HttpClient`, the `baseUrl` and the services its -plugins need. Its calls need nothing from the environment, apart from the -services of their own schemas. +`query` is a plain object. Arrays repeat the key, and `null` and `undefined` +are dropped, so you can pass optional filters as they are. -## Calling +### Sending a body ```ts -import type * as HttpMethod from "effect/http/HttpMethod"; import * as Schema from "effect/Schema"; import type * as Fetch from "@monarcode/effetch"; @@ -191,12 +388,6 @@ const Todo = Schema.Struct({ }); const NewTodo = Schema.Struct({ title: Schema.String, due: Schema.Date }); -export const list = api.get("/todos", { - query: { page: 1, tags: ["work", "home"], search: undefined }, - output: Schema.Array(Todo), -}); -// GET /todos?page=1&tags=work&tags=home - export const create = api.post("/todos", { input: NewTodo, body: { title: "Write docs", due: new Date() }, @@ -209,51 +400,452 @@ export const complete = api.patch("/todos/:id", { output: Todo, }); -export const remove = api.del("/todos/:id", { params: { id: 1 } }); -// No output: succeeds with the raw HttpClientResponse. - export const upload = (file: Blob) => { const form = new FormData(); form.append("file", file); return api.post("/uploads", { body: form }); }; +``` + +A `body` is sent according to its type: + +- an `HttpBody`, a string, an `ArrayBuffer` or typed array, a `Blob` or + `File`, `FormData` and `URLSearchParams` are sent as they are; +- anything else is sent as JSON. + +Add an `input` schema, and `body` is typed by it and encoded with it before +it's sent as JSON. In `create`, `due` is a `Date` in your code and an ISO +string on the wire. + +### Reading the response + +Give a call an `output` schema, and the response body is decoded with it. The +call succeeds with the decoded value, or fails with `DecodeError` if the body +doesn't match. It also sends `Accept: application/json`, unless you set an +`accept` header yourself. + +Without `output`, the call succeeds with the raw `HttpClientResponse`. That's +handy when you only care that the call worked, or want to read the body +yourself: + +```ts +import * as Schema from "effect/Schema"; + +import type * as Fetch from "@monarcode/effetch"; + +declare const api: Fetch.Client; + +// No output: succeeds with the HttpClientResponse. +export const remove = api.del("/todos/:id", { params: { id: 1 } }); + +// Nothing to decode: an empty body (204, HEAD) decodes as JSON null. +export const archive = api.post("/todos/:id/archive", { + params: { id: 1 }, + output: Schema.Void, +}); +``` + +> **Tip:** use `Schema.Finite` rather than `Schema.Number` for numbers. +> Responses are decoded with Effect's JSON codec, where `Schema.Number` also +> decodes the strings `"NaN"`, `"Infinity"` and `"-Infinity"`, and encodes +> those values back to strings. `Schema.Finite` accepts every JSON number and +> rejects the rest. + +### All call options + +| Option | What it does | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `params` | Values for the path's `:params`. Required, with exactly those names, when the path has any; not allowed otherwise. Values are URI-encoded. | +| `query` | An object of query parameters. Arrays repeat the key, and `null` and `undefined` are dropped. | +| `headers` | Headers for this call. They win over the client's, and over the body's `content-type`. | +| `body` | Sent by its type: `HttpBody`, string, `ArrayBuffer` and typed arrays, `Blob`/`File`, `FormData` and `URLSearchParams` as they are, anything else as JSON. | +| `input` | A schema that `body` is encoded with before it's sent as JSON. `body` is then typed by the schema. | +| `output` | A schema the 2xx JSON body is decoded with. The call then sends `Accept: application/json` (unless you set `accept`). Without it, the call succeeds with the raw `HttpClientResponse`. | +| `error` | A schema for the body of error responses, replacing the client's `defaultError`. | +| `plugins` | Plugin overrides for this call (see [Changing plugins for one call](#changing-plugins-for-one-call)). | + +### When the methods aren't enough + +- **The method is only known at runtime.** `api.request` takes it as its + first argument, before the path and the options: + + ```ts + import type * as HttpMethod from "effect/http/HttpMethod"; + + import type * as Fetch from "@monarcode/effetch"; + + declare const api: Fetch.Client; -export const send = (method: HttpMethod.HttpMethod, path: string) => - api.request(method, path); + export const send = (method: HttpMethod.HttpMethod, path: string) => + api.request(method, path); + ``` + +- **You need something the methods don't do, such as streaming a response.** + `api.httpClient` is the configured `HttpClient` underneath, with the base + URL, the default headers and the plugins, but no status check and no + decoding. + +## Handling errors + +### 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`. + +| Error | When | Fields | +| ----------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `NetworkError` | No response: offline, DNS failure, connection refused, CORS rejection. | `method`, `url`, `cause` | +| `HttpError` | A response outside 200–299 (3xx included). | `method`, `url`, `status`, `headers`, `text`, `body` | +| `DecodeError` | A 2xx body that isn't JSON or doesn't match `output`. Only on calls with `output`. | `method`, `url`, `status`, `issue`, `message` | +| `TimeoutError` (plugin) | The `timeout` plugin's limit passed before the response arrived. | `method`, `url`, `duration` | + +### Catching errors + +Errors are part of each call's type, so TypeScript knows exactly which ones +are left to handle. Catch them by tag with `Effect.catchTags`: + +```ts +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type * as Fetch from "@monarcode/effetch"; + +declare const api: Fetch.Client; + +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); +const ApiError = Schema.Struct({ code: Schema.String }); + +export const todo = (id: number) => + api.get("/todos/:id", { params: { id }, output: Todo, error: ApiError }).pipe( + Effect.catchTags({ + HttpError: (error) => + error.status === 404 + ? Effect.succeed(undefined) + : Effect.fail(error.body?.code ?? `HTTP ${error.status}`), + NetworkError: () => Effect.fail("You're offline"), + DecodeError: (error) => Effect.fail(error.message), + }), + ); +``` + +Each handler gets the error with its fields. You don't have to handle them +all: the ones you leave out stay in the type, for code further up to handle. +For a single error, `Effect.catchTag` does the same. + +`Fetch.FetchError` is the union of the three errors, and `Fetch.isFetchError` +tells them apart from other values. + +### Typed error bodies + +APIs usually explain an error in its body, such as +`{ "code": "not_found" }`. Give effetch a schema for it, and +`HttpError.body` is decoded and typed: + +- `defaultError`, on the client, applies to every call; +- `error`, on a call or an [endpoint](#endpoints), replaces it. + +`body` is the decoded body, or `undefined` if it didn't match the schema. +Without a schema, it's the parsed JSON. Reading it never fails the call, and +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. + +### Safe to log + +Errors are made to be logged and reported: + +- `url` never includes the query string, so tokens in queries stay out of + logs. +- `HttpError.headers` are Effect `Headers`: `authorization`, `set-cookie` and + other sensitive headers print as ``. + +### Bugs are defects + +Some mistakes are bugs in the calling code, not failures to recover from at +runtime. Those make the Effect die, as a defect, rather than fail with a typed +error: + +- a missing path parameter; +- a body that can't be encoded; +- a body on a `GET` or `HEAD` request; +- a `baseUrl` with a query string; +- two plugins with the same name; +- a relative URL without a `baseUrl`, outside browsers. + +## Plugins + +Plugins add behaviour to every call a client makes, such as sending a token, +or trying again when the network drops. You pass them to `Fetch.make`, and any +call can turn one off or adjust it. + +### Built-in plugins + +```ts +import * as Effect from "effect/Effect"; + +import * as Fetch from "@monarcode/effetch"; +import { bearer, logger, retry, timeout } from "@monarcode/effetch/plugins"; + +declare const readToken: () => Promise; + +export const makeApi = Fetch.make({ + baseUrl: "https://api.example.com", + plugins: [ + logger({ level: "Info" }), + retry({ times: 2 }), + timeout({ duration: "10 seconds" }), + bearer({ token: () => readToken() }), + ], +}); + +export const withEffectToken = bearer({ token: Effect.succeed("token") }); +``` + +| Plugin | What it does | Per call | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| `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` | +| `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 }` | + +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. + +### 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; +- each retry gets its own timeout, and asks for the token again; +- a timed-out attempt isn't retried. + +### Changing plugins for one call + +Any call can skip a plugin with `false`, and plugins with options take part of +them. TypeScript checks the plugin names and the option types: + +```ts +import * as Effect from "effect/Effect"; + +import * as Fetch from "@monarcode/effetch"; +import { bearer, retry, timeout } from "@monarcode/effetch/plugins"; + +export const program = Effect.gen(function* () { + const api = yield* Fetch.make({ + baseUrl: "https://api.example.com", + plugins: [ + retry(), + timeout({ duration: "10 seconds" }), + bearer({ token: "token" }), + ], + }); + + // Logging in sends no token. + yield* api.post("/auth/login", { + body: { user: "ada" }, + plugins: { bearer: false }, + }); + + // A slow export gets more time, and isn't retried. + yield* api.get("/reports/export", { + plugins: { timeout: { duration: "2 minutes" }, retry: { times: 0 } }, + }); +}); +``` + +## Writing plugins + +When the built-in plugins don't cover what you need, write your own. The +built-in ones are written with the same API. + +### Your first plugin + +A plugin is a name and one or more hooks. This one pins the API version on +every request, with an option that a call can change: + +```ts +import * as Effect from "effect/Effect"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; + +import * as Fetch from "@monarcode/effetch"; + +export const apiVersion = Fetch.plugin({ + name: "apiVersion", + defaults: { version: "2024-06-01" }, + onRequest: (request, ctx) => + Effect.succeed( + HttpClientRequest.setHeader(request, "api-version", ctx.options.version), + ), +}); + +export const program = Effect.gen(function* () { + const api = yield* Fetch.make({ + baseUrl: "https://api.example.com", + plugins: [apiVersion], + }); + yield* api.get("/reports", { + plugins: { apiVersion: { version: "2025-01-01" } }, + }); +}); +``` + +`Fetch.plugin` infers the plugin's name, options, errors and services from +what you give it, so calls can override `apiVersion` by name, and their +options are checked. `ctx.options` is the plugin's `defaults` merged with the +call's override, so a plugin needs `defaults` to take options. + +### Hooks + +| Hook | Runs | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `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. + +### Knowing the route + +`ctx.route` is the call's method and path as written, before the params are +filled in: `"GET /users/:id"`, whether the call was +`api.get("/users/:id", …)` or an [endpoint](#endpoints). It's the same for +every user ID, so it's what to group logs, metrics and error reports by. It's +`undefined` for requests sent through `client.httpClient`. + +A `wrap` reads it from its third argument, an Effect like `options`: + +```ts +import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/http/HttpClient"; + +import * as Fetch from "@monarcode/effetch"; + +declare const report: (error: unknown, route: string | undefined) => void; + +export const errorReporter = Fetch.plugin({ + name: "errorReporter", + onError: (error, ctx) => Effect.sync(() => report(error, ctx.route)), +}); + +export const timing = Fetch.plugin({ + name: "timing", + wrap: (client, _options, route) => + HttpClient.transform(client, (response, request) => + Effect.gen(function* () { + const label = (yield* route) ?? request.url; + return yield* Effect.withLogSpan(response, label); + }), + ), +}); +``` + +### Example: signing out on a 401 + +The classic plugin: send the session's token, and when the API answers `401`, +sign out and fail with a typed `SessionExpired`: + +```ts +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; + +import * as Fetch from "@monarcode/effetch"; + +export class SessionExpired extends Data.TaggedError("SessionExpired")<{}> {} + +export class Session extends Context.Service< + Session, + { + readonly accessToken: Effect.Effect; + readonly signOut: Effect.Effect; + } +>()("Session") {} + +export const auth = Fetch.plugin({ + name: "auth", + onRequest: (request) => + Effect.gen(function* () { + const session = yield* Session; + const token = yield* session.accessToken; + return token === undefined + ? request + : HttpClientRequest.bearerToken(request, token); + }), + onResponse: (response) => + Effect.gen(function* () { + if (response.status !== 401) { + return response; + } + const session = yield* Session; + yield* session.signOut; + return yield* new SessionExpired(); + }), +}); + +// makeApi now needs Session; calls fail with SessionExpired too. +export const makeApi = Fetch.make({ + baseUrl: "https://api.example.com", + plugins: [auth], +}); +``` + +### Example: a deadline with its own error + +A `wrap` that adds its own error, with an option calls can override: + +```ts +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/http/HttpClient"; + +import * as Fetch from "@monarcode/effetch"; + +export class TooSlow extends Data.TaggedError("TooSlow")<{ + readonly url: string; +}> {} + +export const deadline = Fetch.plugin({ + name: "deadline", + defaults: { millis: 5000 }, + wrap: (client, options) => + HttpClient.transform(client, (response, request) => + Effect.gen(function* () { + const { millis } = yield* options; + return yield* Effect.timeoutOrElse(response, { + duration: millis, + orElse: () => Effect.fail(new TooSlow({ url: request.url })), + }); + }), + ), +}); ``` -Methods: `get`, `head` and `options` take no body. `post`, `put`, `patch`, -`del` (because `delete` is a reserved word) and `request(method, path, -options)` do. - -| Option | Description | -| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `params` | Values for the path's `:params`. Required, with exactly those names, when the path has any; not allowed otherwise. Values are URI-encoded. | -| `query` | An object of query parameters. Arrays repeat the key, and `null` and `undefined` are dropped. | -| `headers` | Headers for this call. They win over the client's and over the body's `content-type`. | -| `body` | Sent by its type: `HttpBody`, string, `ArrayBuffer` and typed arrays, `Blob`/`File`, `FormData` and `URLSearchParams` as they are, anything else as JSON. | -| `input` | A schema that `body` is encoded with before it's sent as JSON (a `Date` becomes an ISO string). `body` is then typed by the schema. | -| `output` | A schema the 2xx JSON body is decoded with. The call then sends `Accept: application/json` (unless you set `accept`). Without it, the call succeeds with the raw `HttpClientResponse`. | -| `error` | A schema for non-2xx bodies, replacing the client's. | -| `plugins` | Per-call plugin overrides (see [Per-call overrides](#per-call-overrides)). | +### Tips -An empty 2xx body (204, `HEAD`) decodes as JSON `null`: use `Schema.Void` for -endpoints that return nothing. +- Services a plugin needs are required by `make`, once. Calls don't need + them. Services provided around a call win over those `make` saw. +- Plugin names must be unique. +- Write `wrap` bodies with `Effect.gen`. A ternary that returns two different + Effects can't be typed. +- Pass plugin lists inline, or let TypeScript infer them. Annotating a list as + `Fetch.Plugin.Any[]` erases the plugins' types. -Use `Schema.Finite` rather than `Schema.Number` for numbers. Schemas go through -Effect's JSON codec, where `Schema.Number` also decodes the strings `"NaN"`, -`"Infinity"` and `"-Infinity"`, and encodes those values back to strings. -`Schema.Finite` accepts every JSON number and rejects the rest. +## Endpoints -`client.httpClient` is the configured `HttpClient` underneath: base URL, -default headers and plugins, but no status check and no decoding. Use it for -anything the methods don't cover, such as streaming a response. +Once a call is made from more than one place, you start repeating its path, +its schemas and its rules. An endpoint saves them once, and gives you a +function to call. -## Endpoints +### Defining endpoints -`api.endpoint` saves a call you make often: its route, its schemas, and the -headers and plugin overrides every call needs. You get back a function that -takes the rest: +`api.endpoint` takes a route, the method and the path in one string, and what +every call to it shares. It returns a function that takes the rest: ```ts import * as Context from "effect/Context"; @@ -305,11 +897,17 @@ export const program = Effect.gen(function* () { `getUser({ params: { id } })` sends exactly what `api.get("/users/:id", { output: User, params: { id } })` does, and returns -the same Effect. +the same Effect. Endpoints add no new behaviour: a call goes through the same +plugins, decoding and errors as the method it stands for, so everything above +applies to both. The route is an uppercase method, one space, then a path that starts with `/` -or an absolute `http(s)://` URL. It's checked as you type: `"GTE /users"` and -`"get /users"` don't compile, and editors suggest the methods. +or a full `http(s)://` URL. TypeScript checks it as you type: `"GTE /users"` +and `"get /users"` don't compile, and your editor suggests the methods. + +### Calling an endpoint + +What's fixed goes in the definition, and what changes goes in each call: | Defined once, on the endpoint | Passed with each call | | ----------------------------- | ------------------------- | @@ -319,19 +917,25 @@ or an absolute `http(s)://` URL. It's checked as you type: `"GTE /users"` and - `params` are required when the route's path has any, as with the methods. An endpoint that needs nothing more is called with no arguments: `listUsers()`. -- With an `input` schema, `body` is required and typed by it. `GET`, `HEAD`, - `OPTIONS` and `TRACE` endpoints take no `body` and no `input`. -- A call's headers win over the endpoint's, which win over the client's. - Unlike the client's, an endpoint's headers can set `content-type`. -- A call's plugin override wins over the endpoint's, and when both give - options they're merged. An endpoint defined with `retry: false` can still - be retried in one call with `plugins: { retry: { times: 2 } }`. -- The schemas belong to the endpoint and can't be changed per call. -- Each endpoint has `route`, `method` and `path` properties, for log labels or - cache keys. Plugins see the same route as - [`ctx.route`](#writing-a-plugin). - -### Why endpoints +- With an `input` schema, `body` is required and typed by it. +- `GET`, `HEAD`, `OPTIONS` and `TRACE` endpoints take no `body` and no + `input`, and TypeScript rejects them. + +### Defaults and overrides + +- **Headers.** A call's headers win over the endpoint's, which win over the + client's. Unlike the client's, an endpoint's headers can set + `content-type`. +- **Plugins.** A call's plugin override wins over the endpoint's, and when + both give options, they're merged. An endpoint defined with `retry: false` + can still be retried in one call with `plugins: { retry: { times: 2 } }`. +- **Schemas** belong to the endpoint, and can't be changed per call. + +Each endpoint also has `route`, `method` and `path` properties, for log labels +or cache keys. Plugins see the same route, as +[`ctx.route`](#knowing-the-route). + +### Why use endpoints An HTTP call mixes two kinds of information: @@ -340,16 +944,14 @@ An HTTP call mixes two kinds of information: | the route; the response, error and body schemas | path params, query values, the body | | rules for that route, such as "login sends no token" | a one-off header or plugin override | -With the methods, both are written at every call site. Three calls to +With the methods, you write both at every call site. Three calls to `GET /users/:id` repeat the path and the `User` schema three times, and a mistake in one of them, such as the wrong schema or a forgotten -`bearer: false`, still compiles. An endpoint takes the first column once, -when it's defined, and only the second column when it's called. - -That gives you: +`bearer: false`, still compiles. An endpoint takes the first column once, when +it's defined, and only the second column when it's called. That gives you: - **One place per route.** A path or a schema changes in one line, and the - service that holds the endpoints lists the API you use. + service that holds your endpoints lists the API you use. - **Stricter types than a method can have.** A method only learns its HTTP method and its `input` at the call, so it has to accept both ways. An endpoint knows them up front: `body` is required when there's an `input` @@ -358,19 +960,13 @@ That gives you: is checked as you type. - **Defaults that can't be forgotten.** An endpoint's headers and plugin overrides apply to every call. -- **A name and a route.** Call sites read `users.get(…)` rather than a URL, - and `route`, `method` and `path` are there for query keys and log labels. +- **A name instead of a URL.** Call sites read `users.get(…)`, and `route`, + `method` and `path` are there for query keys and log labels. -Endpoints add no new behaviour: a call goes through the same pipeline as the -method it stands for (plugins, retries, decoding, errors), so the rest of this -README applies to both. A call needs nothing from the environment apart from -its schemas' services, so its Effect runs anywhere: in a service, in a query -function, or with `Effect.runPromise`. - -An endpoint is a saved call, not a contract shared with a server: for that, +An endpoint is a saved call, not a contract shared with a server. For that, use Effect's `HttpApi`. -### Endpoints or methods +### Endpoints or methods? Use an endpoint when a call: @@ -381,32 +977,29 @@ Use an endpoint when a call: - belongs to a backend whose API you want listed in one place. Use a method for one-off calls, and for calls whose method or path is only -known at runtime; use `client.httpClient` for streaming. Mixing them is -normal: keep the client next to its endpoints, for the calls that aren't worth -one. +known at runtime. Mixing them is normal: keep the client next to its +endpoints, for the calls that aren't worth one. Avoid: -- **`api.endpoint(…)(…)` at a call site.** It's a method call written twice: - use the method. +- **`api.endpoint(…)(…)` at a call site.** That's a method call written + twice: use the method. - **Defining endpoints in code that runs on every request.** Define them once, when the layer that holds them is built. -- **Typing a client parameter as `Fetch.Client`.** That's a client without - plugins, and yours won't fit. Use `Effect.Success`, where - `makeApi` is your `Fetch.make(…)`: the type of your own client. - **Spreading several groups of endpoints into one object.** When two use the - same name, the later one silently wins. Nest them: `{ users, todos }`. + same name, the later one silently wins. Nest them instead: + `{ users, todos }`. -### Organizing endpoints +## Structuring an app -Endpoints are made from a client, so they're defined where a client is +Endpoints are made from a client, so you define them wherever a client is available: in a layer that reads it, or in a function that takes it. Where -that is depends on the size of the app. +that is depends on the size of your app. -#### A small app: one service +### A small app -Keep the client in one file and each area's endpoints in another, as a -function of the client. The service makes the client once and calls each +Keep the client in one file, and each area's endpoints in another, as a +function of the client. One service makes the client once, and calls each function once: ```ts @@ -465,11 +1058,12 @@ export const program = Effect.gen(function* () { the plugin overrides in `users.ts` are checked against your plugins, and errors are typed with your `defaultError`. -#### A feature-sliced app: endpoints in each module +### A feature-sliced app -When the app is split into feature modules, each with its own schemas, turn -it around: the client is shared infrastructure that knows nothing about the -modules, and each module defines its endpoints from it, next to its schemas. +When your app is split into feature modules, each with its own schemas, turn +it around. The client becomes shared infrastructure that knows nothing about +the modules, and each module defines its own endpoints from it, next to its +schemas: ```text src/ @@ -485,10 +1079,11 @@ src/ └── runtime.ts modules, on top of clients, on top of HttpClient ``` -This layout is a personal preference, the one effetch's author uses in their -own apps, and effetch doesn't depend on it: rename, merge or split the folders -to suit your project. What carries over is that modules import the client, -never the other way around, and that endpoints are defined once, in a layer. +> **This layout is a personal preference,** the one effetch's author uses in +> their own apps, and effetch doesn't depend on it. Rename, merge or split the +> folders to suit your project. What carries over is that modules import the +> client, never the other way around, and that endpoints are defined once, in +> a layer. ```ts import * as Config from "effect/Config"; @@ -542,24 +1137,28 @@ export const program = Effect.gen(function* () { }); ``` -The client file stays the same size however many endpoints the app has, and -it never imports a module: dependencies point from the modules to the -infrastructure. A module with 20 endpoints has 20 one-line definitions in its -own `data/` folder, beside its own schemas. When they serve separate concerns, -split them into several services, each in its own file. - -Every module's data service is its endpoints, as `TodoApi` is here: one line -per operation, and every type inferred. An operation that isn't one HTTP call -(it combines calls, reshapes a response or adds a domain error) is a workflow -over those endpoints, in the module's `application/` folder, so every -module's `data/` folder looks the same. Declare a service's interface by hand -only when the module needs other implementations, such as an offline one or a -fake for UI work, and implement it with the endpoints. +This scales well as the app grows: + +- **The client file stays the same size,** however many endpoints the app + has, and it never imports a module: dependencies point from the modules to + the infrastructure. +- **A module with 20 endpoints has 20 one-line definitions** in its own + `data/` folder, beside its own schemas. When they serve separate concerns, + split them into several services, each in its own file. +- **Every module's data service is its endpoints,** as `TodoApi` is here: one + line per operation, and every type inferred, so every module's `data/` + folder looks the same. +- **An operation that isn't one HTTP call** (it combines calls, reshapes a + response or adds a domain error) is a workflow over those endpoints, in the + module's `application/` folder. +- **Declare a service's interface by hand only when you need another + implementation,** such as an offline one or a fake for UI work, and + implement it with the endpoints. [`examples/app`](examples/app) is a complete app in this layout, with two modules and two backends. -#### Several backends +### Several backends Make one client per backend, each in its own service, with its own `baseUrl`, `defaultError` and plugins. Each module, or each area's function, uses the @@ -575,269 +1174,11 @@ client of the backend it talks to, and one `HttpClient` layer serves them all. [`examples/backends.ts`](examples/backends.ts) does this in one file, and [`examples/app`](examples/app) across modules. -## Errors - -| Error | When | Fields | -| ----------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------- | -| `NetworkError` | No response: offline, DNS failure, connection refused, CORS rejection. | `method`, `url`, `cause` | -| `HttpError` | A response outside 200–299 (3xx included). | `method`, `url`, `status`, `headers`, `text`, `body` | -| `DecodeError` | A 2xx body that isn't JSON or doesn't match `output`. Only on calls with `output`. | `method`, `url`, `status`, `issue`, `message` | -| `TimeoutError` (plugin) | The `timeout` plugin's limit passed before the response arrived. | `method`, `url`, `duration` | - -- `url` never includes the query string, so tokens in queries stay out of - logs. -- `HttpError.headers` are Effect `Headers`: `authorization`, `set-cookie` and - other sensitive headers print as ``. -- `HttpError.text` is the raw body. `body` is the body decoded with the - call's `error` schema (or the client's `defaultError`), `undefined` if it didn't match, or the parsed JSON when - there's no schema. Reading it never fails the call. - -```ts -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; - -import type * as Fetch from "@monarcode/effetch"; - -declare const api: Fetch.Client; - -const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); -const ApiError = Schema.Struct({ code: Schema.String }); - -export const todo = (id: number) => - api.get("/todos/:id", { params: { id }, output: Todo, error: ApiError }).pipe( - Effect.catchTags({ - HttpError: (error) => - error.status === 404 - ? Effect.succeed(undefined) - : Effect.fail(error.body?.code ?? `HTTP ${error.status}`), - NetworkError: () => Effect.fail("You're offline"), - DecodeError: (error) => Effect.fail(error.message), - }), - ); -``` - -`Fetch.FetchError` is the union of the three, `Fetch.isFetchError` tells them -apart from other values, and `Fetch.HttpError.With` is an `HttpError` -whose `body` is typed as `B`. - -Mistakes in the calling code are defects (the Effect dies) rather than typed -errors: a missing path parameter, a body that can't be encoded, a body on a -`GET` or `HEAD` request, a `baseUrl` with a query string, two plugins with -the same name, and a relative URL without a `baseUrl` outside browsers. - -## Plugins - -### Built-in plugins - -```ts -import * as Effect from "effect/Effect"; - -import * as Fetch from "@monarcode/effetch"; -import { bearer, logger, retry, timeout } from "@monarcode/effetch/plugins"; - -declare const readToken: () => Promise; - -export const makeApi = Fetch.make({ - baseUrl: "https://api.example.com", - plugins: [ - logger({ level: "Info" }), - retry({ times: 2 }), - timeout({ duration: "10 seconds" }), - bearer({ token: () => readToken() }), - ], -}); - -export const withEffectToken = bearer({ token: Effect.succeed("token") }); -``` - -| Plugin | What it does | Per call | -| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | -| `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](#writing-a-plugin) as an annotation. Never logs headers or query strings. | `false` | -| `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 }` | - -- **Order.** The first plugin is the outermost. The order above is the one - to use: `logger` sees the whole call, and each retry gets its own timeout - and asks for the token again. A timed-out attempt isn't retried. -- **Token errors.** A token function that throws, or whose promise rejects, - makes the call die. Pass an Effect to fail with a typed error instead: its - error and services become the client's, and `make` asks for the services. - -### Per-call overrides - -Any plugin can be skipped for one call with `false`, and plugins with options -take a partial of them. Names and option types are checked: - -```ts -import * as Effect from "effect/Effect"; - -import * as Fetch from "@monarcode/effetch"; -import { bearer, retry, timeout } from "@monarcode/effetch/plugins"; - -export const program = Effect.gen(function* () { - const api = yield* Fetch.make({ - baseUrl: "https://api.example.com", - plugins: [ - retry(), - timeout({ duration: "10 seconds" }), - bearer({ token: "token" }), - ], - }); - - yield* api.post("/auth/login", { - body: { user: "ada" }, - plugins: { bearer: false }, - }); - - yield* api.get("/reports/export", { - plugins: { timeout: { duration: "2 minutes" }, retry: { times: 0 } }, - }); -}); -``` - -### Writing a plugin - -`Fetch.plugin` defines a plugin and infers its name, errors, services and -options from the hooks: - -| Hook | Runs | -| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | -| `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.options` is the plugin's `defaults` merged with the call's override, so -a plugin needs `defaults` to have options. `ctx.request` is the request: in -`onResponse`, the one this plugin passed on; in `onError`, the one the call -built. - -`ctx.route` is the call's method and path as written, before the params are -filled in: `"GET /users/:id"`, whether the call was -`api.get("/users/:id", …)` or an [endpoint](#endpoints). It's the same for -every user ID, so it's what to group logs, metrics and error reports by. It's -`undefined` for requests sent through `client.httpClient`. A `wrap` reads it -from its third argument, an Effect like `options`: - -```ts -import * as Effect from "effect/Effect"; -import * as HttpClient from "effect/http/HttpClient"; - -import * as Fetch from "@monarcode/effetch"; - -declare const report: (error: unknown, route: string | undefined) => void; - -export const errorReporter = Fetch.plugin({ - name: "errorReporter", - onError: (error, ctx) => Effect.sync(() => report(error, ctx.route)), -}); - -export const timing = Fetch.plugin({ - name: "timing", - wrap: (client, _options, route) => - HttpClient.transform(client, (response, request) => - Effect.gen(function* () { - const label = (yield* route) ?? request.url; - return yield* Effect.withLogSpan(response, label); - }), - ), -}); -``` - -The classic example turns a `401` into a typed `SessionExpired`, after -signing out: - -```ts -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; - -import * as Fetch from "@monarcode/effetch"; - -export class SessionExpired extends Data.TaggedError("SessionExpired")<{}> {} - -export class Session extends Context.Service< - Session, - { - readonly accessToken: Effect.Effect; - readonly signOut: Effect.Effect; - } ->()("Session") {} - -export const auth = Fetch.plugin({ - name: "auth", - onRequest: (request) => - Effect.gen(function* () { - const session = yield* Session; - const token = yield* session.accessToken; - return token === undefined - ? request - : HttpClientRequest.bearerToken(request, token); - }), - onResponse: (response) => - Effect.gen(function* () { - if (response.status !== 401) { - return response; - } - const session = yield* Session; - yield* session.signOut; - return yield* new SessionExpired(); - }), -}); - -// makeApi now needs Session; calls fail with SessionExpired too. -export const makeApi = Fetch.make({ - baseUrl: "https://api.example.com", - plugins: [auth], -}); -``` - -A `wrap` that adds its own error, with an option calls can override: - -```ts -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import * as HttpClient from "effect/http/HttpClient"; - -import * as Fetch from "@monarcode/effetch"; - -export class TooSlow extends Data.TaggedError("TooSlow")<{ - readonly url: string; -}> {} - -export const deadline = Fetch.plugin({ - name: "deadline", - defaults: { millis: 5000 }, - wrap: (client, options) => - HttpClient.transform(client, (response, request) => - Effect.gen(function* () { - const { millis } = yield* options; - return yield* Effect.timeoutOrElse(response, { - duration: millis, - orElse: () => Effect.fail(new TooSlow({ url: request.url })), - }); - }), - ), -}); -``` - -- Services a plugin needs are required by `make`, once. Calls don't need - them. Services provided around a call win over those `make` saw. -- Plugin names must be unique. -- Write `wrap` bodies with `Effect.gen`. A ternary that returns two different - Effects can't be typed. -- Pass plugin lists inline or let TypeScript infer them. Annotating a list as - `Fetch.Plugin.Any[]` erases the plugins' types. - -[`examples/`](examples) has complete programs with built-in and custom -plugins, endpoints, several backends and a feature-sliced app: its -[README](examples/README.md) says what each one shows. - ## Testing -`make` needs an `HttpClient`, so tests give it a fake one: +effetch never touches the network itself: it sends every request through the +`HttpClient` you provide. In tests, provide a fake one, and check what was +sent: ```ts import * as Effect from "effect/Effect"; @@ -876,7 +1217,9 @@ const todo = await Effect.runPromise( // todo: { id: 1, title: "Write docs" }; sent: ["GET https://api.test/todos/1"] ``` -## Browsers +## Platform notes + +### Browsers Effect's `HttpClient` adds `traceparent` and `b3` tracing headers to every request. Cross-origin calls to APIs that don't allow those headers then fail @@ -908,7 +1251,7 @@ export const ApiLive = Layer.effect(Api)( In a browser, a relative path without a `baseUrl` is resolved against the page's URL. -## React Native +### React Native - effetch doesn't use `URL` itself. Effect's `HttpClient` does, including `searchParams`. Expo provides a standard `URL`. In a React Native app @@ -919,7 +1262,9 @@ page's URL. `resolver.unstable_enablePackageExports = true` in `metro.config.js`. - `FetchHttpClient.layer` uses the global `fetch`. -## Limitations +## Reference + +### Limitations - A path parameter is a whole segment: `/files/:name.json` has one parameter called `name.json`. @@ -929,18 +1274,19 @@ page's URL. headers arrive, not downloading the body. - Two plugins with the same name are only caught when the client is made. -## API +### API `@monarcode/effetch`: -- `make`, `ClientConfig`, `Client`: making a client and its methods. +- `make`, `ClientConfig`, `Client`: [creating a client](#creating-a-client) + and [making calls](#making-calls). - `Endpoint`, `EndpointMethod`, `EndpointDefinition`, `EndpointOptions`, `EndpointBody`, `EndpointArgs`, `EndpointRoute`, `ValidRoute`, `RouteMethod`, `RoutePath`: [endpoints](#endpoints). - `NetworkError`, `HttpError` (and `HttpError.With`), `DecodeError`, - `FetchError`, `isFetchError`: [errors](#errors). + `FetchError`, `isFetchError`: [errors](#handling-errors). - `plugin`, `Plugin` (and the `Plugin.*` helper types), `PluginContext`: - [writing plugins](#writing-a-plugin). + [writing plugins](#writing-plugins). - Option and result types, for wrapping the client in your own functions: `RequestOptions`, `NoBodyRequestOptions`, `BaseOptions`, `BodyOptions`, `ParamsOption`, `QueryInput`, `QueryOf`, `QueryValue`, `AnySchema`, @@ -958,6 +1304,26 @@ page's URL. Every export is documented, with an example, in the type declarations your editor shows. +## Examples + +[`examples/`](examples) has complete programs, typechecked with the package so +they always match the current API: + +- [`basic.ts`](examples/basic.ts): a client in a service, the methods, and + handling each error. +- [`builtins.ts`](examples/builtins.ts): the built-in plugins, and changing + them for one call. +- [`plugins.ts`](examples/plugins.ts): writing plugins, from auth with a + service to a `wrap` with its own error. +- [`endpoints.ts`](examples/endpoints.ts): the same app as `builtins.ts`, with + endpoints. +- [`backends.ts`](examples/backends.ts): two backends in one file, with shared + plugins. +- [`app/`](examples/app): a feature-sliced app, with clients as shared + infrastructure and each module defining its own endpoints. + +Its [README](examples/README.md) walks through each one. + ## Development You need Node 24 and pnpm 10.12.1 (`corepack enable` picks the pinned From 38ac97a6c926c9dce37250d8499fee8d2b37c9de Mon Sep 17 00:00:00 2001 From: monarcode Date: Tue, 13 Oct 2026 10:55:49 +0100 Subject: [PATCH 5/6] chore: dump version number --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4762328..53e966d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@monarcode/effetch", - "version": "0.1.0-alpha.2", + "version": "0.1.0-rc.1", "description": "Typed, schema-driven HTTP calls on Effect v4's HttpClient: path params, object queries, decoded responses and plugins", "keywords": [ "effect", From 4e801297f89856e7bf6f5ddd7c01ab15a4af100a Mon Sep 17 00:00:00 2001 From: monarcode Date: Tue, 13 Oct 2026 11:00:21 +0100 Subject: [PATCH 6/6] chore: release 0.1.0-rc.0 and freeze the API --- CHANGELOG.md | 2 +- README.md | 3 ++- package.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ca429..cb10f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `@monarcode/effetch`, newest first. -## 0.1.0-rc.1 +## 0.1.0-rc.0 ### Added diff --git a/README.md b/README.md index 5f935d8..182313e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ 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 may still change before 0.1.0. +> (`0.1.0-rc.N`). The API is frozen; only fixes and documentation changes +> until 0.1.0. ## Contents diff --git a/package.json b/package.json index 53e966d..c53305d 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.0", "description": "Typed, schema-driven HTTP calls on Effect v4's HttpClient: path params, object queries, decoded responses and plugins", "keywords": [ "effect",