diff --git a/AGENTS.md b/AGENTS.md index 3b919e2..9af2d03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,11 @@ lands on `.d.ts` (no source, no declaration maps are shipped). Every public export needs JSDoc with a description, `@example`, and `@since 0.1.0`. Public types must be **named, exported interfaces/aliases**, not giant - inline conditional types. + inline conditional types. One exception: `CallEffect` and the call + signatures (`NoBodyMethod`, `BodyMethod`, `RequestMethod`, `Endpoint`) + write out the members of `CallError` and `CallServices`, so hovers show + the errors rather than the alias. Keep them in sync; the type tests check + each one against the aliases. 4. **Runtime targets: browsers, React Native (Hermes/Metro), Node ≥ 20, Bun, Deno.** No `node:` imports, no Node globals, **no `new URL()`** (React Native's URL support is incomplete) — join URLs with string operations. No diff --git a/CHANGELOG.md b/CHANGELOG.md index cb10f2e..994c563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,53 @@ All notable changes to `@monarcode/effetch`, newest first. +## 0.1.0-rc.1 + +### Added + +- Endpoints have `output`, `error` and `input` properties: the schemas they + were defined with, for things like decoding cached data or building test + fixtures. `output` and `input` are `undefined` when the endpoint has none. + `error` is the schema `HttpError.body` is decoded with: the endpoint's + `error`, else the client's `defaultError`, else `Schema.Unknown`. Values + typed as `Endpoint` that you build by hand, such as test doubles, need the + three properties too. +- `Fetch.endpoint(route, definition)` defines an endpoint without a client: + frozen data with its `route`, `method`, `path`, schemas and headers, which + can be exported at module level next to its schemas. `client.bind` makes it + an endpoint of that client, the same one `client.endpoint` makes from the + same route and definition. Plugin overrides are given when binding, where + they're checked against the client's plugins, and a definition without an + `error` schema uses the client's `defaultError`. Headers given when binding + win over the definition's. The route is checked by the types, and + `Fetch.endpoint` throws on a malformed one. New types: `UnboundEndpoint`, + `UnboundDefinition`, `BindMethod` and `BindOptions`. Values typed as + `Client` that you build by hand need `bind` too. + +### Changed + +- Calls and endpoints return `Effect.Effect<…>` with their errors and + services written out, rather than `CallEffect<…>`, so editors show what a + call succeeds with, fails with and needs. Hovering a call, or a program + built from calls, shows + `Effect<{ readonly id: number }, NetworkError | DecodeError | HttpError.With, never>` + instead of `CallEffect<…>` and `CallError<…>` with the schemas. The types + are structurally the same, and a `CallEffect` you write yourself prints + the same way once it's used in a program. + - TypeScript can no longer infer `CallEffect`'s type parameters from a + call. A helper that takes `call: CallEffect` and is passed + a call gets their constraints instead, so `Success` is `unknown`. + Accept any Effect: `(call: Effect.Effect)`. + Functions that return a call typed as `CallEffect`, or as + `Effect.Effect` with `CallError` and `CallServices`, work as before. + +### Fixed + +- A `Redacted` query value is sent as its value. From Effect 4.1, + `QueryValue` (Effect's `UrlParams.Coercible`) accepts `Redacted`, but + effetch converted it with `String()`, so `{ token: Redacted.make(key) }` + sent `token=`. + ## 0.1.0-rc.0 ### Added diff --git a/README.md b/README.md index 182313e..934b78a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); const todo = api.get("/todos/:id", { params: { id: 1 }, output: Todo }); // Effect<{ readonly id: number; readonly title: string }, -// NetworkError | HttpError | DecodeError> +// NetworkError | DecodeError | HttpError.With, never> ``` > **Release candidate.** effetch is close to its first stable release @@ -70,6 +70,7 @@ const todo = api.get("/todos/:id", { params: { id: 1 }, output: Todo }); - [Tips](#tips) - [Endpoints](#endpoints) - [Defining endpoints](#defining-endpoints) + - [Defining endpoints without a client](#defining-endpoints-without-a-client) - [Calling an endpoint](#calling-an-endpoint) - [Defaults and overrides](#defaults-and-overrides) - [Why use endpoints](#why-use-endpoints) @@ -370,7 +371,9 @@ 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. +are dropped, so you can pass optional filters as they are. With Effect 4.1 or +later, a value can also be `Redacted`, such as `Redacted.make(apiKey)`: it's +sent as its value, and Effect keeps it out of its own logs and traces. ### Sending a body @@ -906,6 +909,66 @@ 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. +### Defining endpoints without a client + +`api.endpoint` needs a client, and a client only exists inside an Effect, so +those endpoints only exist once the layer that makes them is built. +`Fetch.endpoint` takes the same route and definition with no client, and +returns plain data you can read at module level. `api.bind` then makes it an +endpoint of that client: + +```ts +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 Session = Schema.Struct({ token: Schema.String }); + +// At module level, next to the schemas +export const getUser = Fetch.endpoint("GET /users/:id", { output: User }); +export const login = Fetch.endpoint("POST /auth/login", { output: Session }); + +getUser.route; // "GET /users/:id" +getUser.output; // User + +// Where the client is +export const makeApi = Effect.gen(function* () { + const api = yield* Fetch.make({ + baseUrl: "https://api.example.com", + plugins: [bearer({ token: "token" })], + }); + return { + getUser: api.bind(getUser), + login: api.bind(login, { plugins: { bearer: false } }), + }; +}); +``` + +`api.bind(Fetch.endpoint(route, definition))` is the endpoint +`api.endpoint(route, definition)` makes, with the same type and the same +calls. Since a definition doesn't know its client: + +- **Plugin overrides are given when binding,** where they're checked against + the client's plugins. A definition takes no `plugins`. +- **A definition without an `error` schema** uses the `defaultError` of the + client it's bound to. +- **Headers can be given in both.** The binding's win over the definition's. + +The route is checked as you type, as with `api.endpoint`, and `Fetch.endpoint` +throws on a malformed one, so a bad route fails as soon as its module is +imported. + +Define endpoints this way when: + +- they should be listed next to their schemas, in a file with no client (see + [a feature-sliced app](#a-feature-sliced-app)); +- one definition serves several clients, such as staging and production; +- a tool works from your endpoints, and needs their routes and schemas + without a client. + ### Calling an endpoint What's fixed goes in the definition, and what changes goes in each call: @@ -925,16 +988,22 @@ What's fixed goes in the definition, and what changes goes in each call: ### 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 + client's. A bound endpoint's headers are the binding's over the + definition'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 } }`. +- **Plugins.** A call's plugin override wins over the endpoint's (given to + `api.endpoint`, or when binding), 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). +[`ctx.route`](#knowing-the-route). Its `output`, `error` and `input` +properties are its schemas, for things like decoding cached data or building +test fixtures: `output` and `input` are `undefined` when the endpoint has +none, and `error` is the schema error bodies are decoded with, the +endpoint's, else the client's `defaultError`, else `Schema.Unknown`. ### Why use endpoints @@ -985,17 +1054,22 @@ 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. +- **Making or binding endpoints in code that runs on every request.** Do it + once, when the layer that holds them is built. +- **Passing a bound endpoint's plugin overrides at every call.** A definition + can't hold them, since it doesn't know the client's plugins: give them once, + when binding, as in `api.bind(login, { plugins: { bearer: false } })`. - **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. +An endpoint calls through a client, so it's made, or bound, wherever a client +is available: in a layer that reads it, or in a function that takes it. Its +definition can live elsewhere: [`Fetch.endpoint`](#defining-endpoints-without-a-client) +needs no client, so definitions can sit next to their schemas, and only the +binding needs the client. Where each goes depends on the size of your app. ### A small app @@ -1063,8 +1137,8 @@ errors are typed with your `defaultError`. 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: +the modules. Each module defines its endpoints next to its schemas, with no +client, and binds them to the client in its data layer: ```text src/ @@ -1072,8 +1146,8 @@ src/ │ └── app-api-client.ts the client: base URL, error body, plugins ├── modules/ │ ├── todos/ -│ │ ├── contract/ schemas -│ │ ├── data/ endpoints, made from AppApiClient +│ │ ├── contract/ schemas, and endpoints defined with Fetch.endpoint +│ │ ├── data/ the endpoints, bound to AppApiClient │ │ ├── application/ workflows that take more than one call │ │ └── public.ts │ └── wallet/ @@ -1083,8 +1157,8 @@ src/ > **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. +> client, never the other way around, and that endpoints are bound once, in a +> layer. ```ts import * as Config from "effect/Config"; @@ -1106,15 +1180,24 @@ export class AppApiClient extends Context.Service< export const AppApiClientLive = Layer.effect(AppApiClient)(makeAppApiClient); -// modules/todos/contract/todos.ts +// modules/todos/contract/todos.ts: schemas and endpoints, no client 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 +export const listTodos = Fetch.endpoint("GET /todos", { + output: Schema.Array(Todo), +}); +export const getTodo = Fetch.endpoint("GET /todos/:id", { output: Todo }); +export const createTodo = Fetch.endpoint("POST /todos", { + input: NewTodo, + output: Todo, +}); + +// modules/todos/data/todo.api.ts: the endpoints, bound, as the module's 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 }), + list: client.bind(listTodos), + get: client.bind(getTodo), + create: client.bind(createTodo), })); export class TodoApi extends Context.Service< @@ -1143,9 +1226,10 @@ 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. +- **A module with 20 endpoints has 20 definitions** in its own `contract/` + folder, beside their schemas, and 20 one-line bindings in `data/`. 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. @@ -1156,8 +1240,12 @@ This scales well as the app grows: implementation,** such as an offline one or a fake for UI work, and implement it with the endpoints. +`client.endpoint` fits this layout too: make the endpoints in `data/`, and +import only the schemas from `contract/`. Define them with `Fetch.endpoint` +when something should read the routes and schemas without a client. [`examples/app`](examples/app) is a complete app in this layout, with two -modules and two backends. +modules and two backends: `todos` binds definitions, and `wallet` uses +`client.endpoint`. ### Several backends @@ -1284,6 +1372,9 @@ page's URL. - `Endpoint`, `EndpointMethod`, `EndpointDefinition`, `EndpointOptions`, `EndpointBody`, `EndpointArgs`, `EndpointRoute`, `ValidRoute`, `RouteMethod`, `RoutePath`: [endpoints](#endpoints). +- `endpoint`, `UnboundEndpoint`, `UnboundDefinition`, `BindMethod`, + `BindOptions`: + [endpoints without a client](#defining-endpoints-without-a-client). - `NetworkError`, `HttpError` (and `HttpError.With`), `DecodeError`, `FetchError`, `isFetchError`: [errors](#handling-errors). - `plugin`, `Plugin` (and the `Plugin.*` helper types), `PluginContext`: @@ -1293,7 +1384,9 @@ page's URL. `ParamsOption`, `QueryInput`, `QueryOf`, `QueryValue`, `AnySchema`, `Args`, `Success`, `CallError`, `CallServices`, `CallEffect`, `NoBodyMethod`, `BodyMethod`, `RequestMethod`, `PathParamNames`, - `PathParams`, `PathParamValue`. + `PathParams`, `PathParamValue`. `CallEffect` types a function that + returns a call; to take a call as an argument, accept any + `Effect.Effect`. `@monarcode/effetch/plugins`: diff --git a/examples/README.md b/examples/README.md index b4f303b..9bc7dd4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -37,9 +37,9 @@ app/ ├── modules/ │ ├── todos/ │ │ ├── contract/ -│ │ │ └── todos.ts schemas: Todo, NewTodo, … +│ │ │ └── todos.ts schemas and endpoints: Todo, getTodo, … │ │ ├── data/ -│ │ │ └── todo.api.ts TodoApi: the module's endpoints +│ │ │ └── todo.api.ts TodoApi: the endpoints, bound to a client │ │ ├── todo.layer.ts the module's live layers │ │ └── public.ts what other code may import │ └── wallet/ @@ -63,11 +63,16 @@ Read it in that order: a client, a module, the runtime, then `main.ts`. 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. +2. **A module's endpoints sit next to its schemas, and its `data/` folder + connects them to the client it talks to.** `todos` defines them in + `contract/todos.ts` with `Fetch.endpoint`, which needs no client, and + binds them in `data/` with `client.bind`. `wallet` makes them in `data/` + with `client.endpoint`, importing the schemas from `contract/`. Both give + the same endpoints; use the first when something should read the routes + and schemas without a client. Either way, dependencies point one way: + module → infrastructure, never back. +3. **Endpoints are bound or made once, when the module's layer is built,** + never in code that runs per request. 4. **Every module's data service is its endpoints** (`TodoApi`, `WalletApi`): one line per operation, and every type inferred, so every `data/` folder looks the same. @@ -83,8 +88,8 @@ Read it in that order: a client, a module, the runtime, then `main.ts`. ### 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. +- A module with 20 or more endpoints still has one definition and one line in + `data/` per endpoint, inside the module. 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 diff --git a/examples/app/modules/todos/contract/todos.ts b/examples/app/modules/todos/contract/todos.ts index bd8427c..f2622d7 100644 --- a/examples/app/modules/todos/contract/todos.ts +++ b/examples/app/modules/todos/contract/todos.ts @@ -1,7 +1,11 @@ import * as Schema from "effect/Schema"; -// The module's wire formats. Its endpoints import these; nothing outside the -// module needs to. +import * as Fetch from "@monarcode/effetch"; + +// The module's wire formats, and its endpoints next to the schemas they use. +// The endpoints are defined without a client, so they can be read (route, +// method, schemas) at module level; `data/todo.api.ts` binds them to the +// client the module talks to. export const Todo = Schema.Struct({ id: Schema.Finite, @@ -25,3 +29,16 @@ export type NewTodo = typeof NewTodo.Type; export const TodoPatch = Schema.Struct({ done: Schema.Boolean }); export type TodoPatch = typeof TodoPatch.Type; + +export const listTodos = Fetch.endpoint("GET /todos", { output: TodoPage }); +export const getTodo = Fetch.endpoint("GET /todos/:id", { output: Todo }); +export const createTodo = Fetch.endpoint("POST /todos", { + input: NewTodo, + output: Todo, +}); +export const updateTodo = Fetch.endpoint("PATCH /todos/:id", { + input: TodoPatch, + output: Todo, + headers: { "content-type": "application/merge-patch+json" }, +}); +export const removeTodo = Fetch.endpoint("DELETE /todos/:id"); diff --git a/examples/app/modules/todos/data/todo.api.ts b/examples/app/modules/todos/data/todo.api.ts index 0cd0d51..0a224f0 100644 --- a/examples/app/modules/todos/data/todo.api.ts +++ b/examples/app/modules/todos/data/todo.api.ts @@ -3,25 +3,28 @@ 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"; +import { + createTodo, + getTodo, + listTodos, + removeTodo, + updateTodo, +} 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 +// The module's endpoints are its data service. They're defined in +// `contract/todos.ts`, next to their schemas; here each is bound to the +// client, one line per operation, 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"), + list: client.bind(listTodos), + get: client.bind(getTodo), + create: client.bind(createTodo), + update: client.bind(updateTodo), + remove: client.bind(removeTodo), })); export class TodoApi extends Context.Service< diff --git a/package.json b/package.json index c53305d..943840f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@monarcode/effetch", - "version": "0.1.0-rc.0", + "version": "0.1.0-rc.1", "description": "Typed, schema-driven HTTP calls on Effect v4's HttpClient: path params, object queries, decoded responses and plugins", "keywords": [ "effect", @@ -65,10 +65,10 @@ }, "devDependencies": { "@arethetypeswrong/cli": "0.18.5", - "@effect/language-service": "0.87.4", - "@effect/vitest": "4.0.2", + "@effect/language-service": "0.87.5", + "@effect/vitest": "4.1.0", "@types/node": "26.6.5", - "effect": "4.0.2", + "effect": "4.1.0", "husky": "9.1.7", "oxfmt": "0.72.0", "oxlint": "1.87.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae73ae9..4b2890c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,17 +12,17 @@ importers: specifier: 0.18.5 version: 0.18.5 '@effect/language-service': - specifier: 0.87.4 - version: 0.87.4 + specifier: 0.87.5 + version: 0.87.5 '@effect/vitest': - specifier: 4.0.2 - version: 4.0.2(effect@4.0.2)(vitest@5.0.3(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5))) + specifier: 4.1.0 + version: 4.1.0(effect@4.1.0)(vitest@5.0.3(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5))) '@types/node': specifier: 26.6.5 version: 26.6.5 effect: - specifier: 4.0.2 - version: 4.0.2 + specifier: 4.1.0 + version: 4.1.0 husky: specifier: 9.1.7 version: 9.1.7 @@ -66,14 +66,14 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@effect/language-service@0.87.4': - resolution: {integrity: sha512-AfFn4K45l84LtbQSwoPWc06nrg7OzfV3uJOnIUqimSOWTCIsY1IKBXZfjSsVTq7jDuL9Jtoz8//bZp9+rkEihg==} + '@effect/language-service@0.87.5': + resolution: {integrity: sha512-np/zJgk71/PyHxtoqyUKLYcpQhoRBI+L6+1Z0ILk35JSQ/lvXzwlwOwddYZLDkeC4bdF2ukKekqKlla/F88R5w==} hasBin: true - '@effect/vitest@4.0.2': - resolution: {integrity: sha512-Zb72nu7iUy3rNdBp9J1Xu5oUQGwJ8cOYsUwmYgujj88aqyvASRDjOS01SjlcPViqf77csduetKdCJTVndMNfzw==} + '@effect/vitest@4.1.0': + resolution: {integrity: sha512-+APF69oTYgGj5WCewowk1/udepH74xz4ONyH7d+UfcK0SrF3osiSwsWEyODUpJvWd6MIk5F5PcFJiE4evMuG2A==} peerDependencies: - effect: ^4.0.2 + effect: ^4.1.0 vitest: '>=5.0.0 <6.0.0' '@jridgewell/resolve-uri@3.1.2': @@ -598,8 +598,8 @@ packages: oxc-resolver: optional: true - effect@4.0.2: - resolution: {integrity: sha512-YeYnLqSFrL4+tCOXRrfNP03w8FILoF/wDl+ukraJAGzXrjcS383W7LAAvGBbLatEyktmLxRq+9xJinx7QzXW5A==} + effect@4.1.0: + resolution: {integrity: sha512-lGx2MJJyrXA8RZb2UpQ/0Nkb5Utaimby7oD92+18noBxLPE85IFOvptdzau8dvcxAOwfwHo9iqapbKeFzqQM/Q==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1160,11 +1160,11 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@effect/language-service@0.87.4': {} + '@effect/language-service@0.87.5': {} - '@effect/vitest@4.0.2(effect@4.0.2)(vitest@5.0.3(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)))': + '@effect/vitest@4.1.0(effect@4.1.0)(vitest@5.0.3(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)))': dependencies: - effect: 4.0.2 + effect: 4.1.0 vitest: 5.0.3(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)) '@jridgewell/resolve-uri@3.1.2': {} @@ -1481,7 +1481,7 @@ snapshots: dts-resolver@3.0.0: {} - effect@4.0.2: {} + effect@4.1.0: {} emoji-regex@8.0.0: {} diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index cdb23fe..544f013 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -5,8 +5,11 @@ import ts from "typescript"; const dir = process.cwd(); const probe = path.join(dir, "editor.ts"); -const source = `import * as Fetch from "@monarcode/effetch"; -import { bearer } from "@monarcode/effetch/plugins"; +const source = `import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as Fetch from "@monarcode/effetch"; +import { bearer, type TimeoutPlugin } from "@monarcode/effetch/plugins"; export const make = Fetch./*make*/make({ plugins: [/*bearer*/bearer({ token: "token" })], @@ -27,6 +30,47 @@ 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 Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); +const ApiError = Schema.Struct({ code: Schema.String }); +declare const typed: Fetch.Client<[], typeof ApiError>; +declare const timed: Fetch.Client<[TimeoutPlugin]>; + +export const /*todoCall*/todoCall = typed.get("/todos/:id", { params: { id: 1 }, output: Todo }); +export const /*todoProgram*/todoProgram = Effect.gen(function* () { + const todo = yield* typed.get("/todos/:id", { params: { id: 1 }, output: Todo }); + return todo.title; +}); +const getTodo = typed.endpoint("GET /todos/:id", { output: Todo }); +export const /*endpointCall*/endpointCall = getTodo({ params: { id: 1 } }); +export const /*timedCall*/timedCall = timed.get("/todos", { output: Todo }); +export const /*postCall*/postCall = typed.post("/todos", { + input: Todo, + body: { id: 1, title: "x" }, + output: Todo, +}); +export const /*requestCall*/requestCall = typed.request("PATCH", "/todos/:id", { + params: { id: 1 }, + output: Todo, +}); + +export const defined = Fetch.endpoint(/*definedRoute*/""); +export const badRoute = Fetch.endpoint(/*badRoute*/"GET todos"); +export const badEndpointRoute = typed.endpoint(/*badEndpointRoute*/"GET todos"); +const getTodoDefinition = Fetch./*define*/endpoint("GET /todos/:id", { output: Todo }); +export const /*bound*/bound = typed./*bind*/bind(getTodoDefinition); +export const /*boundCall*/boundCall = bound({ params: { id: 1 } }); +export const boundParams = bound({ params: { /*boundParams*/ } }); + +interface Locale { readonly locale: string } +interface Signer { readonly sign: (s: string) => string } +declare const Localized: Schema.Codec<{ readonly id: number }, unknown, Locale>; +declare const Signed: Schema.Codec; +export const /*servicesCall*/servicesCall = typed.post("/x", { + input: Signed, + body: "x", + output: Localized, +}); `; const config = ts.getParsedCommandLineOfConfigFile( @@ -82,6 +126,8 @@ for (const [marker, file, docs] of [ ["get", "Client.d.ts", "Sends a `GET` request"], ["bearer", path.join("plugins", "bearer.d.ts"), "Sends `Authorization"], ["endpoint", "Client.d.ts", "Defines an endpoint"], + ["define", "Endpoint.d.ts", "Defines an endpoint without a client"], + ["bind", "Client.d.ts", "Binds an endpoint defined with"], ]) { const definitions = service.getDefinitionAtPosition(probe, at(marker)) ?? []; const targets = definitions.map((definition) => @@ -101,6 +147,7 @@ for (const [marker, file, docs] of [ expect( marker === "get" || marker === "endpoint" || + marker === "bind" || (tags.includes("example") && tags.includes("since")), `Hover on ${marker} lacks @example or @since: ${tags.join(", ")}`, ); @@ -128,6 +175,32 @@ expect( `endpoint("") doesn't suggest the methods: ${routes.slice(0, 10).join(", ")}`, ); +const definedRoutes = ( + service.getCompletionsAtPosition(probe, at("definedRoute") + 1, undefined) + ?.entries ?? [] +).map((entry) => entry.name); +expect( + definedRoutes.includes("GET /") && definedRoutes.includes("POST /"), + `Fetch.endpoint("") doesn't suggest the methods: ${definedRoutes.slice(0, 10).join(", ")}`, +); + +// A malformed route is reported on the route itself, as a route error, for +// both ways of defining an endpoint. +const diagnostics = service.getSemanticDiagnostics(probe); +for (const marker of ["badRoute", "badEndpointRoute"]) { + const messages = diagnostics + .filter((diagnostic) => diagnostic.start === at(marker)) + .map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, " "), + ); + expect( + messages.length === 1 && + messages[0].includes("GET /") && + !messages[0].includes("No overload matches"), + `A malformed route at ${marker} isn't reported as a route error: ${messages.join("; ") || "no error"}`, + ); +} + const endpointParams = ( service.getCompletionsAtPosition(probe, at("endpointParams"), undefined) ?.entries ?? [] @@ -137,6 +210,15 @@ expect( `params of a "GET /users/:id" endpoint don't complete id: ${endpointParams.slice(0, 10).join(", ")}`, ); +const boundParams = ( + service.getCompletionsAtPosition(probe, at("boundParams"), undefined) + ?.entries ?? [] +).map((entry) => entry.name); +expect( + boundParams.includes("id"), + `params of a bound "GET /todos/:id" endpoint don't complete id: ${boundParams.slice(0, 10).join(", ")}`, +); + const endpointHover = ts.displayPartsToString( service.getQuickInfoAtPosition(probe, at("getUser"))?.displayParts, ); @@ -145,6 +227,77 @@ expect( `Hover on an endpoint doesn't name its type and route: ${endpointHover}`, ); +const boundHover = ts.displayPartsToString( + service.getQuickInfoAtPosition(probe, at("bound"))?.displayParts, +); +expect( + boundHover.includes('Endpoint<"GET /todos/:id"'), + `Hover on a bound endpoint doesn't name its type and route: ${boundHover}`, +); + +// Splits `text` at `separator`, outside any brackets. +const splitTopLevel = (text, separator) => { + const parts = []; + let depth = 0; + let start = 0; + for (let index = 0; index < text.length; index++) { + if ("<{([".includes(text[index])) { + depth++; + } else if (">})]".includes(text[index])) { + depth--; + } else if (depth === 0 && text.startsWith(separator, index)) { + parts.push(text.slice(start, index).trim()); + start = index + separator.length; + } + } + return [...parts, text.slice(start).trim()]; +}; + +// The member names of a union, such as `["NetworkError", "HttpError.With"]`. +const members = (union) => + splitTopLevel(union, "|") + .map((member) => member.replace(/^Fetch\./, "").replace(/<.*$/, "")) + .sort(); + +// The error and services of a hover such as `const x: Effect.Effect`. +const errorsAndServices = (hover) => { + const flat = hover.replace(/\s+/g, " "); + const start = flat.indexOf(": Effect.Effect<"); + if (start === -1 || !flat.endsWith(">")) { + return undefined; + } + const args = splitTopLevel( + flat.slice(start + ": Effect.Effect<".length, -1), + ",", + ); + return args.length === 3 + ? { errors: members(args[1]), services: members(args[2]) } + : undefined; +}; + +const callErrors = ["DecodeError", "HttpError.With", "NetworkError"]; +for (const [marker, errors, services] of [ + ["todoCall", callErrors, ["never"]], + ["todoProgram", callErrors, ["never"]], + ["endpointCall", callErrors, ["never"]], + ["boundCall", callErrors, ["never"]], + ["postCall", callErrors, ["never"]], + ["requestCall", callErrors, ["never"]], + ["timedCall", [...callErrors, "TimeoutError"].sort(), ["never"]], + ["servicesCall", callErrors, ["Locale", "Signer"]], +]) { + const hover = ts.displayPartsToString( + service.getQuickInfoAtPosition(probe, at(marker))?.displayParts, + ); + const shown = errorsAndServices(hover); + expect( + shown !== undefined && + shown.errors.join(" | ") === errors.join(" | ") && + shown.services.join(" | ") === services.join(" | "), + `Hover on ${marker} doesn't show Effect.Effect<…, ${errors.join(" | ")}, ${services.join(" | ")}>: ${hover}`, + ); +} + 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 b10ad32..e517334 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -185,6 +185,14 @@ const rejected = ( api.endpoint("GET /todos", { input: Todo }); // @ts-expect-error the body is required with an input api.endpoint("POST /todos", { input: Todo })(); + // @ts-expect-error the method is misspelled + Fetch.endpoint("GTE /todos"); + // @ts-expect-error plugin overrides are given when binding + Fetch.endpoint("GET /todos", { plugins: { bearer: false } }); + // @ts-expect-error no plugin is called "requestID" + api.bind(Fetch.endpoint("GET /todos"), { plugins: { requestID: false } }); + // @ts-expect-error the :id path param is required + api.bind(Fetch.endpoint("GET /todos/:id"))({}); }; void rejected; @@ -214,6 +222,13 @@ const endpointProgram = Effect.gen(function* () { ) { throw new Error("Unexpected endpoint route, method or path"); } + if ( + getTodo.output !== Todo || + getTodo.error !== Schema.Unknown || + getTodo.input !== undefined + ) { + throw new Error("Unexpected endpoint output, error or input"); + } return yield* getTodo({ params: { id: 2 } }); }).pipe(Effect.provideService(HttpClient.HttpClient, mock)); @@ -228,6 +243,41 @@ if ( throw new Error(`Unexpected endpoint request: ${seen.at(-1)}`); } +// Defined at module level, without a client, then bound. +const getTodoDefinition = Fetch.endpoint("GET /todos/:id", { output: Todo }); +if ( + !Object.isFrozen(getTodoDefinition) || + getTodoDefinition.route !== "GET /todos/:id" || + getTodoDefinition.method !== "GET" || + getTodoDefinition.path !== "/todos/:id" || + getTodoDefinition.output !== Todo +) { + throw new Error("Unexpected definition"); +} + +const boundProgram = Effect.gen(function* () { + const api = yield* Fetch.make({ + baseUrl: "https://api.test", + plugins: [requestId, routeSeen], + }); + const getTodo = api.bind(getTodoDefinition, { + plugins: { requestId: { id: "bound" } }, + }); + if (getTodo.route !== "GET /todos/:id" || getTodo.output !== Todo) { + throw new Error("Unexpected bound endpoint"); + } + return yield* getTodo({ params: { id: 3 } }); +}).pipe(Effect.provideService(HttpClient.HttpClient, mock)); + +const viaBind: { readonly id: number } = await Effect.runPromise(boundProgram); +if ( + viaBind.id !== 1 || + seen.at(-1) !== "GET https://api.test/todos/3 bound undefined" || + routes.join() !== "GET /todos/:id,GET /todos/:id" +) { + throw new Error(`Unexpected bound request: ${seen.at(-1)}`); +} + console.log("smoke: ok"); TS diff --git a/src/Client.ts b/src/Client.ts index 0525347..1431dda 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 { BindMethod, EndpointMethod } from "./Endpoint.js"; import type { NetworkError } from "./Errors.js"; -import { makeEndpoint, type RuntimeDefinition } from "./internal/endpoint.js"; +import { + bindEndpoint, + makeEndpoint, + type RuntimeBindOptions, + type RuntimeDefinition, + type RuntimeUnboundEndpoint, +} from "./internal/endpoint.js"; import { makeApplyDefaults, mapClientErrors } from "./internal/pipeline.js"; import { applyPlugins, @@ -363,6 +369,59 @@ export interface Client< * ``` */ readonly endpoint: EndpointMethod; + + /** + * Binds an endpoint defined with `Fetch.endpoint` to this client. You get + * back the same endpoint `client.endpoint` makes from the same route and + * definition: `api.bind(Fetch.endpoint("GET /users/:id", { output: User }))` + * is `api.endpoint("GET /users/:id", { output: User })`. + * + * - Without its own `error`, the endpoint uses this client's + * `defaultError`. + * - `plugins` overrides are given here, checked against this client's + * plugins. A call's own override wins. + * - `headers` given here are sent over the definition's. A call's own + * header wins over both. + * + * Binding does nothing on its own, and one definition can be bound to + * several clients: each endpoint sends to its own client's `baseUrl`, + * with its plugins. + * + * @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 Session = Schema.Struct({ token: Schema.String }); + * + * // Next to the schemas, without a client. + * const getUser = Fetch.endpoint("GET /users/:id", { output: User }); + * const refresh = Fetch.endpoint("POST /auth/refresh", { output: Session }); + * + * const makeApi = Effect.gen(function* () { + * const api = yield* Fetch.make({ + * baseUrl: "https://api.example.com", + * plugins: [bearer({ token: "token" })], + * }); + * return { + * getUser: api.bind(getUser), + * refresh: api.bind(refresh, { plugins: { bearer: false } }), + * }; + * }); + * + * class Api extends Context.Service>()("Api") {} + * + * const program = Effect.gen(function* () { + * const { getUser } = yield* Api; + * return yield* getUser({ params: { id: 1 } }); + * }); + * ``` + */ + readonly bind: BindMethod; } /** @@ -502,7 +561,11 @@ export const make = < patch: call("PATCH"), del: call("DELETE"), endpoint: (route: string, definition?: RuntimeDefinition) => - makeEndpoint(call, route, definition), + makeEndpoint(call, config.defaultError, route, definition), + bind: ( + definition: RuntimeUnboundEndpoint, + options?: RuntimeBindOptions, + ) => bindEndpoint(call, config.defaultError, definition, options), }; return client as unknown as Client; }); diff --git a/src/Endpoint.ts b/src/Endpoint.ts index d0c067f..315f093 100644 --- a/src/Endpoint.ts +++ b/src/Endpoint.ts @@ -1,12 +1,19 @@ +import type * as Effect from "effect/Effect"; import type * as Headers from "effect/http/Headers"; import type * as HttpMethod from "effect/http/HttpMethod"; +import type { DecodeError, HttpError, NetworkError } from "./Errors.js"; +import { + makeUnboundEndpoint, + type RuntimeUnboundDefinition, + type RuntimeUnboundEndpoint, +} from "./internal/endpoint.js"; import type { AnySchema, - CallEffect, ParamsOption, QueryInput, QueryOf, + Success, } from "./Options.js"; import type { Plugin } from "./Plugin.js"; @@ -248,12 +255,13 @@ type Requirements< > = 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. + * An endpoint made by `client.endpoint` or `client.bind`: 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. + * labels and cache keys, and its `output`, `error` and `input` schemas, for + * things like decoding cached data or building test fixtures. * * - `R`: the route, such as `"GET /users/:id"`. * - `O`, `EB`, `I`: the `output`, `error` and `input` schemas (`O` and `I` @@ -270,6 +278,7 @@ type Requirements< * const users = (api: Fetch.Client) => { * const getUser = api.endpoint("GET /users/:id", { output: User }); * getUser.route; // "GET /users/:id" + * getUser.output; // User * return getUser({ params: { id: 1 } }); * }; * ``` @@ -285,13 +294,32 @@ export interface Endpoint< > { = QueryInput>( ...args: EndpointArgs> - ): CallEffect; + ): Effect.Effect< + Success, + // CallError and CallServices, written out (see CallEffect) + | NetworkError + | HttpError.With + | (O extends AnySchema ? DecodeError : never) + | Plugin.Errors, + | (O extends AnySchema ? O["DecodingServices"] : never) + | EB["DecodingServices"] + | (I extends AnySchema ? I["EncodingServices"] : never) + >; /** 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 schema the response is decoded with, or `undefined` without `output`. */ + readonly output: O; + /** + * The schema `HttpError.body` is decoded with: the endpoint's `error`, or + * else the client's `defaultError`, or else `Schema.Unknown`. + */ + readonly error: EB; + /** The schema the body is encoded with, or `undefined` without `input`. */ + readonly input: I; } /** @@ -329,3 +357,258 @@ export interface EndpointMethod< definition?: EndpointDefinition, ): Endpoint; } + +/** + * What {@link endpoint} defines an endpoint with, besides its route: an + * {@link EndpointDefinition} without `plugins`. 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`. Without it, the + * endpoint uses the `defaultError` of the client it's bound to. + * - `input`: encodes `body` before it's sent as JSON, and makes `body` + * required on every call. Not allowed on `GET`, `HEAD`, `OPTIONS` and + * `TRACE` endpoints, which take no body. + * - `headers`: sent with every call, over the client's headers. Headers + * given when binding, then a call's own, win over them. Unlike the + * client's headers, these can set `content-type`. + * + * Plugin overrides are given when binding, in {@link BindOptions}: a + * definition doesn't know which plugins a client has. + * + * - `R`: the route, for the method and the path. + * - `O`, `EB`, `I`: the `output`, `error` and `input` schemas (`undefined` + * when absent). + * + * @example + * ```ts + * import * as Schema from "effect/Schema"; + * import type * as Fetch from "@monarcode/effetch"; + * + * const Todo = Schema.Struct({ id: Schema.Finite, done: Schema.Boolean }); + * const TodoPatch = Schema.Struct({ done: Schema.Boolean }); + * + * const update: Fetch.UnboundDefinition< + * "PATCH /todos/:id", + * typeof Todo, + * undefined, + * typeof TodoPatch + * > = { + * input: TodoPatch, + * output: Todo, + * headers: { "content-type": "application/merge-patch+json" }, + * }; + * ``` + * + * @since 0.1.0 + */ +export interface UnboundDefinition< + R extends string, + O extends AnySchema | undefined, + EB extends AnySchema | undefined, + I extends AnySchema | undefined, +> { + /** Decodes the 2xx JSON body. Without it, calls return the raw response. */ + readonly output?: O; + /** Decodes non-2xx bodies into `HttpError.body`. Without it, the client's `defaultError` does. */ + readonly error?: EB; + /** Encodes `body` as JSON, and makes `body` required on every call. */ + readonly input?: NoBodyRoute extends true ? undefined : I; + /** Headers for every call, over the client's. Binding and call headers win. */ + readonly headers?: Headers.Input | undefined; +} + +/** + * An endpoint defined without a client, by {@link endpoint}: its route, + * schemas and headers, as plain data. `client.bind` turns it into an + * {@link Endpoint} of that client, the same one `client.endpoint` makes + * from the same route and definition. + * + * It needs no client, so it can be exported from module level next to its + * schemas, read by code that has no client (cache keys, test fixtures, + * tools), and bound to more than one client. + * + * - `R`: the route, such as `"GET /users/:id"`. + * - `O`, `EB`, `I`: the `output`, `error` and `input` schemas (`undefined` + * when absent; without `error`, the bound endpoint uses the client's + * `defaultError`). + * + * @example + * ```ts + * import * as Schema from "effect/Schema"; + * import * as Fetch from "@monarcode/effetch"; + * + * const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }); + * + * export const getUser = Fetch.endpoint("GET /users/:id", { output: User }); + * getUser.route; // "GET /users/:id" + * getUser.output; // User + * + * const user = (api: Fetch.Client, id: number) => + * api.bind(getUser)({ params: { id } }); + * ``` + * + * @since 0.1.0 + */ +export interface UnboundEndpoint< + R extends string, + O extends AnySchema | undefined, + EB extends AnySchema | undefined, + I extends AnySchema | undefined, +> { + /** 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 schema the response is decoded with, or `undefined` without `output`. */ + readonly output: O; + /** + * The schema `HttpError.body` is decoded with, or `undefined` to use the + * `defaultError` of the client it's bound to. + */ + readonly error: EB; + /** The schema the body is encoded with, or `undefined` without `input`. */ + readonly input: I; + /** The headers sent with every call, or `undefined` without any. */ + readonly headers: Headers.Headers | undefined; +} + +/** + * What `client.bind` takes besides the definition: headers and plugin + * overrides for every call of the bound endpoint. + * + * - `headers`: sent with every call, over the definition's headers. A + * call's own header of the same name wins. + * - `plugins`: per-plugin overrides, as in a call's `plugins` option, + * checked against the client's plugins. A call's own override for a + * plugin wins, and when both give options, they're merged. + * + * - `Ps`: the client's plugins. + * + * @example + * ```ts + * import type * as Fetch from "@monarcode/effetch"; + * + * const options: Fetch.BindOptions<[Fetch.Plugin<"bearer">]> = { + * headers: { "x-client": "web" }, + * plugins: { bearer: false }, + * }; + * ``` + * + * @since 0.1.0 + */ +export interface BindOptions> { + /** Headers for every call, over the definition'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 signature of `client.bind`: turns an {@link UnboundEndpoint} into an + * {@link Endpoint} of the client, the same one `client.endpoint` makes from + * the same route and definition. + * + * - `Ps`: the client's plugins. + * - `DefaultEB`: the client's `defaultError` schema, used when the + * definition has no `error`. + * + * @example + * ```ts + * import * as Schema from "effect/Schema"; + * import * as Fetch from "@monarcode/effetch"; + * + * declare const bind: Fetch.BindMethod<[], Schema.Unknown>; + * + * const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); + * const getTodo = bind(Fetch.endpoint("GET /todos/:id", { output: Todo })); + * ``` + * + * @since 0.1.0 + */ +export interface BindMethod< + Ps extends ReadonlyArray, + DefaultEB extends AnySchema, +> { + < + R extends string, + O extends AnySchema | undefined, + EB extends AnySchema | undefined, + I extends AnySchema | undefined, + >( + definition: UnboundEndpoint, + options?: BindOptions, + ): Endpoint; +} + +/** + * Defines an endpoint without a client: a route like `"GET /users/:id"` and + * the schemas and headers to call it with. You get back plain data, an + * {@link UnboundEndpoint}, that you can export from module level next to + * its schemas. `client.bind(definition)` turns it into an {@link Endpoint} + * of that client: the same one `client.endpoint(route, definition)` makes. + * + * - The route is checked as you type, as with `client.endpoint`. A route + * that isn't valid (which the types prevent) throws here, when the + * module loads. + * - With an `input` schema, `body` is required on every call and typed by + * it. `GET`, `HEAD`, `OPTIONS` and `TRACE` endpoints take no `input`. + * - Without `error`, the endpoint uses the `defaultError` of the client + * it's bound to. + * - Plugin overrides are given when binding: a definition doesn't know + * which plugins a client has. + * + * Use it when endpoints should be listed next to their schemas, read + * without a client, or bound to more than one client. Otherwise + * `client.endpoint` does the same in one step. + * + * @example + * ```ts + * 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 Credentials = Schema.Struct({ user: Schema.String, password: Schema.String }); + * const Session = Schema.Struct({ token: Schema.String }); + * const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }); + * + * // At module level, without a client. + * export const login = Fetch.endpoint("POST /auth/login", { + * input: Credentials, + * output: Session, + * }); + * export const getUser = Fetch.endpoint("GET /users/:id", { output: User }); + * + * // Bound once there is a client. + * const makeApi = Effect.gen(function* () { + * const api = yield* Fetch.make({ + * baseUrl: "https://api.example.com", + * plugins: [bearer({ token: "token" })], + * }); + * return { + * login: api.bind(login, { plugins: { bearer: false } }), + * getUser: api.bind(getUser), + * }; + * }); + * ``` + * + * @since 0.1.0 + */ +export function endpoint< + const R extends string, + O extends AnySchema | undefined = undefined, + EB extends AnySchema | undefined = undefined, + I extends AnySchema | undefined = undefined, +>( + route: ValidRoute, + definition?: UnboundDefinition, +): UnboundEndpoint; +export function endpoint( + route: string, + definition?: RuntimeUnboundDefinition, +): RuntimeUnboundEndpoint { + return makeUnboundEndpoint(route, definition); +} diff --git a/src/Options.ts b/src/Options.ts index 4979712..0cd0148 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -12,7 +12,9 @@ import type { Plugin } from "./Plugin.js"; /** * A single query value. effetch drops `null` and `undefined` (Effect's own * `UrlParams` would send `null` as the string `"null"`); other values are - * converted with `String()`. + * converted with `String()`. With Effect 4.1 or later, a value can also be + * `Redacted`: it's sent as its value, and stays redacted in Effect's own logs + * and traces. * * @example * ```ts @@ -315,7 +317,7 @@ export type Success = O extends AnySchema * Schema.Struct<{ code: Schema.String }>, * [] * >; - * // ^? NetworkError | HttpError.With<{ readonly code: string }> | DecodeError + * // ^? NetworkError | DecodeError | HttpError.With<{ readonly code: string }> * ``` * * @since 0.1.0 @@ -368,6 +370,10 @@ export type CallServices< * has none). * - `Ps`: the client's plugins. * + * Use it to type functions that return a call. To take a call as an + * argument, accept any `Effect.Effect`: TypeScript can't infer + * these parameters from a call. + * * @example * ```ts * import * as Schema from "effect/Schema"; @@ -379,7 +385,7 @@ export type CallServices< * Schema.Unknown, * [] * >; - * // ^? Effect<{ readonly id: number }, NetworkError | HttpError.With | DecodeError, never> + * // ^? Effect<{ readonly id: number }, NetworkError | DecodeError | HttpError.With, never> * ``` * * @since 0.1.0 @@ -389,7 +395,19 @@ export type CallEffect< EB extends AnySchema, I extends AnySchema | undefined, Ps extends ReadonlyArray, -> = Effect.Effect, CallError, CallServices>; +> = Effect.Effect< + Success, + // CallError and CallServices, written out: through the aliases, editors + // print `CallError<…>` rather than the members. The method and endpoint + // signatures spell them out the same way. + | NetworkError + | HttpError.With + | (O extends AnySchema ? DecodeError : never) + | Plugin.Errors, + | (O extends AnySchema ? O["DecodingServices"] : never) + | EB["DecodingServices"] + | (I extends AnySchema ? I["EncodingServices"] : never) +>; /** * The signature of `get`, `head` and `options`: no `body` or `input`. @@ -423,7 +441,16 @@ export interface NoBodyMethod< >( path: P, ...args: Args> - ): CallEffect; + ): Effect.Effect< + Success, + // CallError and CallServices, written out (see CallEffect) + | NetworkError + | HttpError.With + | (O extends AnySchema ? DecodeError : never) + | Plugin.Errors, + | (O extends AnySchema ? O["DecodingServices"] : never) + | EB["DecodingServices"] + >; } /** @@ -464,7 +491,17 @@ export interface BodyMethod< >( path: P, ...args: Args> - ): CallEffect; + ): Effect.Effect< + Success, + // CallError and CallServices, written out (see CallEffect) + | NetworkError + | HttpError.With + | (O extends AnySchema ? DecodeError : never) + | Plugin.Errors, + | (O extends AnySchema ? O["DecodingServices"] : never) + | EB["DecodingServices"] + | (I extends AnySchema ? I["EncodingServices"] : never) + >; } /** @@ -506,5 +543,15 @@ export interface RequestMethod< method: HttpMethod.HttpMethod, path: P, ...args: Args> - ): CallEffect; + ): Effect.Effect< + Success, + // CallError and CallServices, written out (see CallEffect) + | NetworkError + | HttpError.With + | (O extends AnySchema ? DecodeError : never) + | Plugin.Errors, + | (O extends AnySchema ? O["DecodingServices"] : never) + | EB["DecodingServices"] + | (I extends AnySchema ? I["EncodingServices"] : never) + >; } diff --git a/src/index.ts b/src/index.ts index cd49bbc..7613b1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,9 @@ export { make } from "./Client.js"; export type { Client, ClientConfig } from "./Client.js"; +export { endpoint } from "./Endpoint.js"; export type { + BindMethod, + BindOptions, Endpoint, EndpointArgs, EndpointBody, @@ -10,6 +13,8 @@ export type { EndpointRoute, RouteMethod, RoutePath, + UnboundDefinition, + UnboundEndpoint, ValidRoute, } from "./Endpoint.js"; export { diff --git a/src/internal/endpoint.ts b/src/internal/endpoint.ts index be7c24f..385923c 100644 --- a/src/internal/endpoint.ts +++ b/src/internal/endpoint.ts @@ -1,5 +1,6 @@ import * as Headers from "effect/http/Headers"; import * as HttpMethod from "effect/http/HttpMethod"; +import * as Schema from "effect/Schema"; import type { AnySchema } from "../Options.js"; import { mergeOverrides, type OverrideMap } from "./plugins.js"; @@ -21,8 +22,43 @@ export interface RuntimeEndpoint { readonly route: string; readonly method: HttpMethod.HttpMethod; readonly path: string; + /** The `output` schema, or `undefined` without one. */ + readonly output: AnySchema | undefined; + /** The `error` schema, else the client's `defaultError`, else `Schema.Unknown`. */ + readonly error: AnySchema; + /** The `input` schema, or `undefined` without one. */ + readonly input: AnySchema | undefined; } +/** A definition made without a client, as seen at runtime: no plugins. */ +export type RuntimeUnboundDefinition = Omit; + +/** An endpoint defined without a client, as seen at runtime. */ +export interface RuntimeUnboundEndpoint { + readonly route: string; + readonly method: HttpMethod.HttpMethod; + readonly path: string; + readonly output: AnySchema | undefined; + readonly error: AnySchema | undefined; + readonly input: AnySchema | undefined; + readonly headers: Headers.Headers | undefined; +} + +/** What a definition is bound with, as seen at runtime. */ +export interface RuntimeBindOptions { + readonly headers?: Headers.Input | undefined; + readonly plugins?: OverrideMap | undefined; +} + +/** `over` merged over `base`, where either may be absent. */ +const mergeHeaders = ( + base: Headers.Headers | undefined, + over: Headers.Input | undefined, +): Headers.Input | undefined => + base === undefined || over === undefined + ? (over ?? base) + : Headers.merge(base, Headers.fromInput(over)); + /** * Splits `route` into its method and path, with the same rules as the * `EndpointRoute` type: an uppercase method, one space, then a path that @@ -52,9 +88,13 @@ export const parseRoute = ( * 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. + * + * `defaultError` is the client's, used for the endpoint's `error` property + * when the definition has none. Calls fall back to it on their own. */ export const makeEndpoint = ( call: (method: HttpMethod.HttpMethod) => Call, + defaultError: AnySchema | undefined, route: string, definition: RuntimeDefinition = {}, ): RuntimeEndpoint => { @@ -70,11 +110,57 @@ export const makeEndpoint = ( 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)), + headers: mergeHeaders(headers, options.headers), plugins: mergeOverrides(definition.plugins, options.plugins), }); - return Object.assign(endpoint, { route, method, path }); + return Object.assign(endpoint, { + route, + method, + path, + output: definition.output, + error: definition.error ?? defaultError ?? Schema.Unknown, + input: definition.input, + }); }; + +/** + * Defines an endpoint without a client: the route, checked now, and the + * definition as frozen data, for `bindEndpoint` to turn into an endpoint. + */ +export const makeUnboundEndpoint = ( + route: string, + definition: RuntimeUnboundDefinition = {}, +): RuntimeUnboundEndpoint => { + const { method, path } = parseRoute(route); + return Object.freeze({ + route, + method, + path, + output: definition.output, + error: definition.error, + input: definition.input, + headers: + definition.headers === undefined + ? undefined + : Headers.fromInput(definition.headers), + }); +}; + +/** + * Binds a definition to a client: the endpoint `makeEndpoint` makes from the + * same route and definition, with the binding's headers over the + * definition's and its plugin overrides. + */ +export const bindEndpoint = ( + call: (method: HttpMethod.HttpMethod) => Call, + defaultError: AnySchema | undefined, + unbound: RuntimeUnboundEndpoint, + options: RuntimeBindOptions = {}, +): RuntimeEndpoint => + makeEndpoint(call, defaultError, unbound.route, { + output: unbound.output, + error: unbound.error, + input: unbound.input, + headers: mergeHeaders(unbound.headers, options.headers), + plugins: options.plugins, + }); diff --git a/src/internal/query.ts b/src/internal/query.ts index db97269..165e5ee 100644 --- a/src/internal/query.ts +++ b/src/internal/query.ts @@ -1,7 +1,23 @@ import * as UrlParams from "effect/http/UrlParams"; +import * as Redacted from "effect/Redacted"; import type { QueryInput } from "../Options.js"; +/** One query value as `UrlParams` holds it. */ +type Param = UrlParams.UrlParams["params"][number][1]; + +/** + * A `Redacted` value is kept as it is (Effect 4.1 accepts them in + * `UrlParams`, unwraps them for the URL and redacts them in its own logs); + * anything else goes through `String()`. + * + * The cast compiles with Effect 4.0 too, where `Param` is `string`. There, + * only a value cast to `QueryValue` can be `Redacted`, and Effect 4.0 sends + * it as ``, as before 4.1. + */ +const toParam = (value: unknown): Param => + (Redacted.isRedacted(value) ? value : String(value)) as Param; + /** * Converts the `query` option into `UrlParams`, in key order. Arrays repeat * the key; `null` and `undefined` values and array items are dropped. @@ -12,17 +28,17 @@ import type { QueryInput } from "../Options.js"; export const toUrlParams = ( query: QueryInput | undefined, ): UrlParams.UrlParams => { - const entries: Array = []; + const entries: Array = []; if (query !== undefined) { for (const [key, value] of Object.entries(query)) { if (Array.isArray(value)) { for (const item of value) { if (item !== null && item !== undefined) { - entries.push([key, String(item)]); + entries.push([key, toParam(item)]); } } } else if (value !== null && value !== undefined) { - entries.push([key, String(value)]); + entries.push([key, toParam(value)]); } } } diff --git a/test/client.test.ts b/test/client.test.ts index 24debfe..233e944 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -23,6 +23,7 @@ import { type RecordedRequest, type Script, } from "./utils/mockClient.js"; +import { redacted, supportsRedactedQuery } from "./utils/redacted.js"; import { reply } from "./utils/reply.js"; const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); @@ -168,6 +169,29 @@ describe("path params and query", () => { }), ); + it.effect.runIf(supportsRedactedQuery)( + "send a Redacted query value as its value, not in errors", + () => + Effect.gen(function* () { + const { mock, api } = yield* setup([ + reply.json({}, { status: 500 }), + ]); + const error = yield* Effect.flip( + api.get("/todos", { + query: { token: redacted("s3cret"), page: 1 }, + }), + ); + expect(wire(mock).url).toBe( + "https://api.test/todos?token=s3cret&page=1", + ); + expect(error).toMatchObject({ + _tag: "HttpError", + url: "https://api.test/todos", + }); + expect(JSON.stringify(error)).not.toContain("s3cret"); + }), + ); + it.effect("make the call defect when a param is missing at runtime", () => Effect.gen(function* () { const { mock, api } = yield* setup([reply.json({})]); diff --git a/test/endpoint.test.ts b/test/endpoint.test.ts index 42c50c4..3e18232 100644 --- a/test/endpoint.test.ts +++ b/test/endpoint.test.ts @@ -343,6 +343,37 @@ describe("the endpoint", () => { }), ); + it.effect("carries its schemas, or undefined without them", () => + Effect.gen(function* () { + const { api } = yield* setup([]); + const createTodo = api.endpoint("POST /todos", { + input: NewTodo, + output: Todo, + error: ApiError, + }); + expect(createTodo.input).toBe(NewTodo); + expect(createTodo.output).toBe(Todo); + expect(createTodo.error).toBe(ApiError); + const listTodos = api.endpoint("GET /todos"); + expect(listTodos.input).toBeUndefined(); + expect(listTodos.output).toBeUndefined(); + }), + ); + + it.effect( + "carries the client's defaultError, else Schema.Unknown, without an error", + () => + Effect.gen(function* () { + const { api: typed } = yield* setup([], { defaultError: ApiError }); + expect(typed.endpoint("GET /todos").error).toBe(ApiError); + expect(typed.endpoint("GET /todos", { error: NewTodo }).error).toBe( + NewTodo, + ); + const { api: plain } = yield* setup([]); + expect(plain.endpoint("GET /todos").error).toBe(Schema.Unknown); + }), + ); + it.effect("keeps its schemas, whatever a call passes", () => Effect.gen(function* () { const { api } = yield* setup([reply.json(todo)]); @@ -390,3 +421,168 @@ describe("the endpoint", () => { }), ); }); + +describe("defined without a client", () => { + it.effect("send, once bound, what client.endpoint sends", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json(todo)], { + plugins: [flags], + }); + const definition = { + input: NewTodo, + output: Todo, + headers: { "x-endpoint": "1" }, + }; + const viaEndpoint = api.endpoint("POST /lists/:list/todos", { + ...definition, + plugins: { flags: { a: "endpoint" } }, + }); + const viaBind = api.bind( + Fetch.endpoint("POST /lists/:list/todos", definition), + { plugins: { flags: { a: "endpoint" } } }, + ); + const options = { + params: { list: "home" }, + query: { notify: true }, + headers: { "x-trace": "1" }, + body: { + title: "Write docs", + due: new Date("2026-01-02T03:04:05.000Z"), + }, + }; + expect(yield* viaEndpoint(options)).toEqual(todo); + expect(yield* viaBind(options)).toEqual(todo); + expect(sent(wire(mock, 1))).toEqual(sent(wire(mock, 0))); + expect(wire(mock, 1)).toMatchObject({ + method: "POST", + url: `${base}/lists/home/todos?notify=true`, + }); + expect(wire(mock, 1).headers).toMatchObject({ + "x-endpoint": "1", + "x-a": "endpoint", + "x-trace": "1", + }); + expect(viaBind.route).toBe("POST /lists/:list/todos"); + expect(viaBind.output).toBe(Todo); + expect(viaBind.input).toBe(NewTodo); + }), + ); + + it.effect( + "send the definition's headers under the binding's and a call's", + () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json({})], { + headers: { "x-client": "client" }, + }); + const list = api.bind( + Fetch.endpoint("GET /todos", { + headers: { + "X-Definition": "definition", + "x-bound": "definition", + "x-call": "definition", + }, + }), + { headers: { "x-bound": "bound", "X-Call": "bound" } }, + ); + yield* list(); + yield* list({ headers: { "x-call": "call" } }); + expect(wire(mock, 0).headers).toMatchObject({ + "x-client": "client", + "x-definition": "definition", + "x-bound": "bound", + "x-call": "bound", + }); + expect(wire(mock, 1).headers).toMatchObject({ + "x-definition": "definition", + "x-bound": "bound", + "x-call": "call", + }); + }), + ); + + it.effect("take plugin overrides when bound, merged with a call's", () => + Effect.gen(function* () { + const { mock, api } = yield* setup([reply.json({})], { + plugins: [flags, tag], + }); + const login = api.bind(Fetch.endpoint("POST /login"), { + plugins: { tag: false, flags: { a: "bound", b: "bound" } }, + }); + yield* login(); + yield* login({ plugins: { flags: { b: "call" } } }); + expect(wire(mock, 0).headers["x-tag"]).toBeUndefined(); + expect(wire(mock, 0).headers).toMatchObject({ + "x-a": "bound", + "x-b": "bound", + }); + expect(wire(mock, 1).headers).toMatchObject({ + "x-a": "bound", + "x-b": "call", + }); + }), + ); + + it.effect("use each client's base URL, plugins and default error", () => + Effect.gen(function* () { + const getTodo = Fetch.endpoint("GET /todos/:id", { output: Todo }); + const first = yield* setup([reply.json(todo)], { plugins: [tag] }); + const second = yield* setup( + [reply.json({ code: "gone" }, { status: 404 })], + { baseUrl: "https://staging.test", defaultError: ApiError }, + ); + const fromFirst = first.api.bind(getTodo); + const fromSecond = second.api.bind(getTodo); + expect(yield* fromFirst({ params: { id: 1 } })).toEqual(todo); + const error = yield* Effect.flip(fromSecond({ params: { id: 1 } })); + expect(wire(first.mock)).toMatchObject({ url: `${base}/todos/1` }); + expect(wire(first.mock).headers["x-tag"]).toBe("yes"); + expect(wire(second.mock)).toMatchObject({ + url: "https://staging.test/todos/1", + }); + expect(wire(second.mock).headers["x-tag"]).toBeUndefined(); + expect(error).toMatchObject({ status: 404, body: { code: "gone" } }); + expect(fromFirst.error).toBe(Schema.Unknown); + expect(fromSecond.error).toBe(ApiError); + }), + ); + + it("are frozen data with their route, schemas and headers", () => { + const update = Fetch.endpoint("PATCH /todos/:id", { + input: NewTodo, + output: Todo, + error: ApiError, + headers: { "Content-Type": "application/merge-patch+json" }, + }); + expect(Object.isFrozen(update)).toBe(true); + expect(update.route).toBe("PATCH /todos/:id"); + expect(update.method).toBe("PATCH"); + expect(update.path).toBe("/todos/:id"); + expect(update.input).toBe(NewTodo); + expect(update.output).toBe(Todo); + expect(update.error).toBe(ApiError); + expect(update.headers?.["content-type"]).toBe( + "application/merge-patch+json", + ); + const list = Fetch.endpoint("GET /todos"); + expect(list.output).toBeUndefined(); + expect(list.error).toBeUndefined(); + expect(list.input).toBeUndefined(); + expect(list.headers).toBeUndefined(); + }); + + it.each([ + "get /todos", + "GTE /todos", + "GET/todos", + "GET /todos", + "GET todos", + "/todos", + "", + 42, + ])("throw for the invalid route %j", (route) => { + expect(() => Fetch.endpoint(route as "GET /")).toThrow( + /^Invalid endpoint route .*: expected an uppercase HTTP method/, + ); + }); +}); diff --git a/test/exports.test.ts b/test/exports.test.ts index 527239f..c65ee5f 100644 --- a/test/exports.test.ts +++ b/test/exports.test.ts @@ -9,6 +9,7 @@ describe("public entry points", () => { "DecodeError", "HttpError", "NetworkError", + "endpoint", "isFetchError", "make", "plugin", diff --git a/test/query.test.ts b/test/query.test.ts index f3559eb..df7bdc2 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -3,6 +3,7 @@ import * as UrlParams from "effect/http/UrlParams"; import { describe, expect, it } from "vitest"; import { toUrlParams } from "../src/internal/query.js"; +import { redacted, supportsRedactedQuery } from "./utils/redacted.js"; const entries = (query: Parameters[0]) => [ ...toUrlParams(query), @@ -62,6 +63,17 @@ describe("toUrlParams", () => { expect(entries({ tags: [] })).toEqual([]); }); + it.runIf(supportsRedactedQuery)( + "keeps Redacted values, and serialises them as their value", + () => { + const token = redacted("s3cret"); + const params = toUrlParams({ token, ids: [redacted("a b"), 2] }); + expect(entries({ token })).toEqual([["token", token]]); + expect(UrlParams.toString(params)).toBe("token=s3cret&ids=a+b&ids=2"); + expect(JSON.stringify(params)).not.toContain("s3cret"); + }, + ); + it("serialises with URL encoding", () => { expect( UrlParams.toString( diff --git a/test/types/client.test-d.ts b/test/types/client.test-d.ts index af09ba0..7de3dff 100644 --- a/test/types/client.test-d.ts +++ b/test/types/client.test-d.ts @@ -12,11 +12,16 @@ import { describe, expectTypeOf, it } from "vitest"; import type { DecodeError, HttpError, NetworkError } from "../../src/Errors.js"; import type { + AnySchema, BaseOptions, BodyMethod, + CallEffect, + CallError, + CallServices, NoBodyMethod, QueryInput, RequestMethod, + Success, } from "../../src/Options.js"; import type { Plugin } from "../../src/Plugin.js"; @@ -267,6 +272,38 @@ describe("return type", () => { { readonly code: string } | undefined >(); }); + + it("fits CallEffect, CallError and CallServices in generic code", () => { + // The methods write these types out (so hovers show the members); + // wrappers generic over the schemas must still accept them. + const getAs = ( + method: NoBodyMethod, + output: O, + ): CallEffect => method("/x", { output }); + const postAs = ( + output: O, + input: I, + body: I["Type"], + ): Effect.Effect< + Success, + CallError, + CallServices + > => post("/x", { output, input, body }); + const requestAs = < + O extends AnySchema, + P extends ReadonlyArray, + >( + method: RequestMethod, + output: O, + ): Effect.Effect< + Success, + CallError, + CallServices + > => method("DELETE", "/x", { output }); + expectTypeOf(getAs).toBeFunction(); + expectTypeOf(postAs).toBeFunction(); + expectTypeOf(requestAs).toBeFunction(); + }); }); describe("services", () => { @@ -296,6 +333,60 @@ describe("services", () => { const call = get("/x", { error: Localized }); expectTypeOf>().toEqualTypeOf(); }); + + class Signer extends Context.Service< + Signer, + { readonly sign: (s: string) => string } + >()("Signer") {} + const Signed = Schema.String.pipe( + Schema.decode({ + decode: SchemaGetter.passthrough(), + encode: SchemaGetter.transformEffect((s: string) => + Effect.map(Effect.service(Signer), (signer) => signer.sign(s)), + ), + }), + ); + type Spelled< + O extends AnySchema, + EB extends AnySchema, + I extends AnySchema, + > = Effect.Effect, CallError, CallServices>; + + it("match CallEffect, CallError and CallServices exactly", () => { + // The signatures write these types out, so check they still agree. + const fromOutput = get("/x", { output: Localized, error: ApiError }); + expectTypeOf(fromOutput).toEqualTypeOf< + Spelled + >(); + expectTypeOf(fromOutput).toEqualTypeOf< + CallEffect + >(); + const fromError = get("/x", { output: Todo, error: Localized }); + expectTypeOf(fromError).toEqualTypeOf< + Spelled + >(); + const posted = post("/x", { input: Signed, body: "x", output: Todo }); + expectTypeOf(posted).toEqualTypeOf< + Spelled + >(); + expectTypeOf(posted).toEqualTypeOf< + CallEffect + >(); + const requested = request("PATCH", "/x", { + input: Signed, + body: "x", + output: Localized, + }); + expectTypeOf(requested).toEqualTypeOf< + Spelled + >(); + expectTypeOf(requested).toEqualTypeOf< + CallEffect + >(); + expectTypeOf>().toEqualTypeOf< + Locale | Signer + >(); + }); }); describe("plugin overrides", () => { diff --git a/test/types/endpoint.test-d.ts b/test/types/endpoint.test-d.ts index fb4508e..04651c4 100644 --- a/test/types/endpoint.test-d.ts +++ b/test/types/endpoint.test-d.ts @@ -11,16 +11,26 @@ import * as SchemaGetter from "effect/SchemaGetter"; import { describe, expectTypeOf, it } from "vitest"; import type { Client } from "../../src/Client.js"; +import { endpoint as define } from "../../src/Endpoint.js"; import type { + BindMethod, Endpoint, EndpointArgs, EndpointMethod, EndpointRoute, RouteMethod, RoutePath, + UnboundEndpoint, ValidRoute, } from "../../src/Endpoint.js"; import type { DecodeError, HttpError, NetworkError } from "../../src/Errors.js"; +import type { + AnySchema, + CallEffect, + CallError, + CallServices, + Success, +} from "../../src/Options.js"; import type { Plugin } from "../../src/Plugin.js"; class TimeoutError extends Data.TaggedError("TimeoutError")<{}> {} @@ -32,6 +42,7 @@ type Ps = [ ]; declare const endpoint: EndpointMethod; +declare const bind: BindMethod; const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); const NewTodo = Schema.Struct({ title: Schema.String, due: Schema.Date }); @@ -118,6 +129,38 @@ describe("route", () => { }); }); +describe("schemas", () => { + it("are the ones defined, or undefined", () => { + const createTodo = endpoint("POST /todos", { + input: NewTodo, + output: Todo, + error: ApiError, + }); + expectTypeOf(createTodo.output).toEqualTypeOf(); + expectTypeOf(createTodo.error).toEqualTypeOf(); + expectTypeOf(createTodo.input).toEqualTypeOf(); + const listTodos = endpoint("GET /todos"); + expectTypeOf(listTodos.output).toEqualTypeOf(); + expectTypeOf(listTodos.input).toEqualTypeOf(); + }); + + it("give error the client's defaultError when the endpoint has none", () => { + expectTypeOf( + endpoint("GET /todos").error, + ).toEqualTypeOf(); + const withDefault = null as unknown as EndpointMethod< + [], + typeof ApiError + >; + expectTypeOf(withDefault("GET /todos").error).toEqualTypeOf< + typeof ApiError + >(); + expectTypeOf( + withDefault("GET /todos", { error: NewTodo }).error, + ).toEqualTypeOf(); + }); +}); + describe("params", () => { const getTodo = endpoint("GET /todos/:id", { output: Todo }); const listTodos = endpoint("GET /todos", { output: Schema.Array(Todo) }); @@ -296,6 +339,21 @@ describe("return type", () => { Endpoint<"GET /todos/:id", typeof Todo, Schema.Unknown, undefined, Ps> >(); }); + + it("fits CallEffect, CallError and CallServices in generic code", () => { + const callAs = ( + getTodo: Endpoint<"GET /todos/:id", O, EB, undefined, Ps>, + ): Effect.Effect< + Success, + CallError, + CallServices + > => getTodo({ params: { id: 1 } }); + const callAsEffect = ( + getTodo: Endpoint<"GET /todos/:id", O, EB, undefined, Ps>, + ): CallEffect => getTodo({ params: { id: 1 } }); + expectTypeOf(callAs).toBeFunction(); + expectTypeOf(callAsEffect).toBeFunction(); + }); }); describe("services", () => { @@ -336,6 +394,49 @@ describe("services", () => { }); expectTypeOf>().toEqualTypeOf(); }); + + it("match CallEffect, CallError and CallServices exactly", () => { + // The call signature writes these types out, so check they still agree. + const fromOutput = endpoint("GET /x", { + output: Localized, + error: ApiError, + })(); + expectTypeOf(fromOutput).toEqualTypeOf< + Effect.Effect< + Success, + CallError, + CallServices + > + >(); + expectTypeOf(fromOutput).toEqualTypeOf< + CallEffect + >(); + const fromError = endpoint("GET /x", { + output: Todo, + error: Localized, + })(); + expectTypeOf(fromError).toEqualTypeOf< + Effect.Effect< + Todo, + CallError, + CallServices + > + >(); + const posted = endpoint("POST /x", { + input: LocalizedInput, + output: Todo, + })({ body: "x" }); + expectTypeOf(posted).toEqualTypeOf< + Effect.Effect< + Todo, + CallError, + CallServices + > + >(); + expectTypeOf(posted).toEqualTypeOf< + CallEffect + >(); + }); }); describe("on a client", () => { @@ -354,3 +455,128 @@ describe("on a client", () => { >().toEqualTypeOf(); }); }); + +describe("defined without a client", () => { + const ApiErrorBind = null as unknown as BindMethod; + + it("checks the route", () => { + define("GET /todos/:id"); + define("POST https://uploads.test/files/:name"); + // @ts-expect-error misspelled method + define("GTE /todos"); + // @ts-expect-error no space + define("GET/todos"); + // @ts-expect-error path doesn't start with "/" + define("GET todos"); + const route: string = "GET /todos"; + // @ts-expect-error the route must be known statically + define(route); + }); + + it("carries its route, schemas and headers", () => { + const updateTodo = define("PATCH /todos/:id", { + input: NewTodo, + output: Todo, + headers: { "content-type": "application/merge-patch+json" }, + }); + expectTypeOf(updateTodo).toEqualTypeOf< + UnboundEndpoint< + "PATCH /todos/:id", + typeof Todo, + undefined, + typeof NewTodo + > + >(); + expectTypeOf(updateTodo.method).toEqualTypeOf<"PATCH">(); + expectTypeOf(updateTodo.path).toEqualTypeOf<"/todos/:id">(); + const listTodos = define("GET /todos"); + expectTypeOf(listTodos).toEqualTypeOf< + UnboundEndpoint<"GET /todos", undefined, undefined, undefined> + >(); + }); + + it("rejects input on routes without a body, and plugin overrides", () => { + // @ts-expect-error GET takes no input + define("GET /todos", { input: NewTodo }); + // @ts-expect-error HEAD takes no input + define("HEAD /todos", { input: NewTodo }); + // @ts-expect-error plugin overrides are given when binding + define("GET /todos", { plugins: { bearer: false } }); + }); + + it("binds to the endpoint client.endpoint makes", () => { + expectTypeOf( + bind(define("GET /todos/:id", { output: Todo })), + ).toEqualTypeOf(endpoint("GET /todos/:id", { output: Todo })); + expectTypeOf( + bind( + define("POST /todos", { + input: NewTodo, + output: Todo, + error: ApiError, + }), + ), + ).toEqualTypeOf( + endpoint("POST /todos", { + input: NewTodo, + output: Todo, + error: ApiError, + }), + ); + expectTypeOf(bind(define("DELETE /todos/:id"))).toEqualTypeOf( + endpoint("DELETE /todos/:id"), + ); + expectTypeOf( + bind(define("GET /todos/:id", { output: Todo })), + ).toEqualTypeOf< + Endpoint<"GET /todos/:id", typeof Todo, Schema.Unknown, undefined, Ps> + >(); + }); + + it("needs params and body as client.endpoint's do", () => { + const getTodo = bind(define("GET /todos/:id", { output: Todo })); + getTodo({ params: { id: 1 } }); + // @ts-expect-error params are required + getTodo(); + // @ts-expect-error GET takes no body + getTodo({ params: { id: 1 }, body: {} }); + const createTodo = bind(define("POST /todos", { input: NewTodo })); + createTodo({ body: { title: "x", due: new Date() } }); + // @ts-expect-error the body is required with an input + createTodo(); + // @ts-expect-error the body is typed by the input + createTodo({ body: { title: 1, due: new Date() } }); + }); + + it("uses the client's default error unless it has its own", () => { + const list = ApiErrorBind(define("GET /todos")); + expectTypeOf(list.error).toEqualTypeOf(); + type E = Effect.Error>; + expectTypeOf["body"]>().toEqualTypeOf< + { readonly code: string } | undefined + >(); + const own = ApiErrorBind(define("GET /todos", { error: NewTodo })); + expectTypeOf(own.error).toEqualTypeOf(); + }); + + it("takes plugin overrides when bound, typed from the client's plugins", () => { + const login = bind(define("POST /login"), { + headers: { "x-client": "web" }, + plugins: { bearer: false }, + }); + login({ plugins: { retry: { times: 0 }, timeout: { duration: "1s" } } }); + // @ts-expect-error unknown plugin name + bind(define("POST /login"), { plugins: { logger: false } }); + // @ts-expect-error bearer only accepts false + bind(define("POST /login"), { plugins: { bearer: {} } }); + }); + + it("is available on a client, with its plugins and default error", () => { + const getTodo = define("GET /todos/:id", { output: Todo }); + const bound = (api: Client) => + api.bind(getTodo, { plugins: { retry: { times: 1 } } }); + expectTypeOf>().toEqualTypeOf< + Endpoint<"GET /todos/:id", typeof Todo, typeof ApiError, undefined, Ps> + >(); + }); +}); diff --git a/test/utils/redacted.ts b/test/utils/redacted.ts new file mode 100644 index 0000000..e17f777 --- /dev/null +++ b/test/utils/redacted.ts @@ -0,0 +1,20 @@ +import * as UrlParams from "effect/http/UrlParams"; +import * as Redacted from "effect/Redacted"; + +import type * as Fetch from "../../src/index.js"; + +/** + * A `Redacted` query value. Effect 4.1's types accept one, but CI also runs + * the tests with Effect 4.0, the oldest supported version, whose types don't. + */ +export const redacted = (value: string): Fetch.QueryValue => + Redacted.make(value) as never; + +/** + * Whether the installed Effect sends a `Redacted` query value as its value + * (4.1 and later). Effect 4.0 sends ``. + */ +export const supportsRedactedQuery = + UrlParams.toString( + UrlParams.make([["key", Redacted.make("value") as never]]), + ) === "key=value";