diff --git a/CHANGELOG.md b/CHANGELOG.md index 604e15c..cb10f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,52 @@ All notable changes to `@monarcode/effetch`, newest first. +## 0.1.0-rc.0 + +### 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`. + +### 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 + 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 1fb519e..182313e 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,50 +28,153 @@ 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 is frozen; only fixes and documentation changes +> until 0.1.0. ## Contents -- [Why](#why) -- [Install](#install) -- [Quick start](#quick-start) -- [Making a client](#making-a-client) -- [Calling](#calling) -- [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 +## Why effetch -Effect gives you two ways to call HTTP APIs: +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. -- `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. +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. -effetch sits in between, for calling backends you don't own, one call at a -time. You get: +### At a glance -- 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. +Here's the same call written with `HttpClient` alone, and with effetch: -It has no platform-specific code, and targets browsers, React Native, -Node 20+, Bun and Deno. +```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"; -## Install +import type * as Fetch from "@monarcode/effetch"; + +declare const api: Fetch.Client; + +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); + +// 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); + }); + +// With effetch +export const getTodo = (id: number) => + api.get("/todos/:id", { params: { id }, output: Todo }); +``` + +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 @@ -83,12 +187,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"; @@ -117,18 +226,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"; @@ -138,7 +264,7 @@ const ApiError = Schema.Struct({ code: Schema.String, message: Schema.String }); const session: { accessToken?: string } = {}; -const make = Fetch.make({ +export const makeApi = Fetch.make({ baseUrl: Config.String("API_URL"), headers: { "x-client": "web" }, defaultError: ApiError, @@ -149,31 +275,106 @@ const make = Fetch.make({ bearer({ token: () => session.accessToken }), ], }); +``` + +Every option is optional: -export class Api extends Context.Service>()( +| 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", ) {} -export const ApiLive = Layer.effect(Api)(make).pipe( +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)); ``` -| 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. | +Two things worth knowing: -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. +- **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`. -## Calling +## 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), +}); +``` + +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. + +`query` is a plain object. Arrays repeat the key, and `null` and `undefined` +are dropped, so you can pass optional filters as they are. + +### 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"; @@ -188,12 +389,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() }, @@ -206,47 +401,97 @@ 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: -export const send = (method: HttpMethod.HttpMethod, path: string) => - api.request(method, path); +- 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, +}); ``` -Methods: `get`, `head` and `options` take no body. `post`, `put`, `patch`, -`del` (because `delete` is a reserved word) and `request(method, path, -options)` do. +> **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. -| Option | Description | +### 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`. | +| `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. | +| `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 non-2xx bodies, replacing the client's. | -| `plugins` | Per-call plugin overrides (see [Per-call overrides](#per-call-overrides)). | +| `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"; -An empty 2xx body (204, `HEAD`) decodes as JSON `null`: use `Schema.Void` for -endpoints that return nothing. + import type * as Fetch from "@monarcode/effetch"; -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. + declare const api: Fetch.Client; -`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. + export const send = (method: HttpMethod.HttpMethod, path: string) => + api.request(method, path); + ``` -## Errors +- **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 | | ----------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------- | @@ -255,13 +500,10 @@ anything the methods don't cover, such as streaming a response. | `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. +### 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"; @@ -276,27 +518,68 @@ 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), + }), ); ``` -`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`. +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 -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. +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 @@ -307,7 +590,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" }), @@ -323,21 +606,27 @@ 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](#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 }` | -- **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. +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: -### Per-call overrides +- `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. -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: +### 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"; @@ -355,36 +644,111 @@ export const program = Effect.gen(function* () { ], }); + // 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 a plugin +## 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. -`Fetch.plugin` defines a plugin and infers its name, errors, services and -options from the hooks: +### Your first plugin -| 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. | +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: -`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. +```ts +import * as Effect from "effect/Effect"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; -The classic example turns a `401` into a typed `SessionExpired`, after -signing out: +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"; @@ -425,13 +789,15 @@ 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], }); ``` +### Example: a deadline with its own error + A `wrap` that adds its own error, with an option calls can override: ```ts @@ -461,20 +827,359 @@ export const deadline = Fetch.plugin({ }); ``` +### Tips + - 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 +- 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 + +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. + +### Defining endpoints + +`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"; +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 makeApi = 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. 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 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 | +| ----------------------------- | ------------------------- | +| `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`, 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: + +| 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, 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: + +- **One place per route.** A path or a schema changes in one line, and the + 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` + (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 instead of a URL.** Call sites read `users.get(…)`, and `route`, + `method` and `path` are there for query keys and log labels. + +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. 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.** 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. +- **Spreading several groups of endpoints into one object.** When two use the + same name, the later one silently wins. Nest them instead: + `{ users, todos }`. + +## Structuring an app + +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 your app. + +### A small app + +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 +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", + 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 } }); +}); +``` + +`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 + +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/ +├── 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 } }); +}); +``` + +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 + +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. ## 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"; @@ -513,7 +1218,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 @@ -529,21 +1236,23 @@ 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)); ``` 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 @@ -554,26 +1263,31 @@ 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`. -- 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. - 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`, @@ -591,6 +1305,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 diff --git a/banner.png b/banner.png index d4e3d33..526b349 100644 Binary files a/banner.png and b/banner.png differ 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 new file mode 100644 index 0000000..5878f43 --- /dev/null +++ b/examples/backends.ts @@ -0,0 +1,154 @@ +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 makeUsersApi = 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 makeWalletApi = 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)(makeUsersApi), + Layer.effect(WalletApi)(makeWalletApi), +).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.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 new file mode 100644 index 0000000..b3c8455 --- /dev/null +++ b/examples/endpoints.ts @@ -0,0 +1,115 @@ +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 makeApi = 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)(makeApi).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.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) => + program(email, password).pipe(Effect.provide(ApiLive)); 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/package.json b/package.json index 4762328..c53305d 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.0", "description": "Typed, schema-driven HTTP calls on Effect v4's HttpClient: path params, object queries, decoded responses and plugins", "keywords": [ "effect", 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..0525347 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 { @@ -53,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, @@ -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 makeApi = 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; } /** @@ -353,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), * ); * ``` @@ -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/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 5e66b87..2621a3a 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. * @@ -63,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" })], * }); @@ -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/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" })], * }); 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",