From 86cef48aae8ab044afa5865be7dcfb0b30ede611 Mon Sep 17 00:00:00 2001 From: monarcode Date: Fri, 30 Oct 2026 12:58:56 +0100 Subject: [PATCH 1/9] fix(types): show success and error types in call hovers --- CHANGELOG.md | 11 ++++++++ README.md | 2 +- scripts/editor-check.mjs | 40 ++++++++++++++++++++++++-- src/Endpoint.ts | 16 +++++++++-- src/Options.ts | 53 +++++++++++++++++++++++++++++++---- test/types/client.test-d.ts | 37 ++++++++++++++++++++++++ test/types/endpoint.test-d.ts | 22 +++++++++++++++ 7 files changed, 170 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb10f2e..849d160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to `@monarcode/effetch`, newest first. +## 0.1.0-rc.1 + +### Fixed + +- Hovering a call, or a program built from calls, shows its success, error + and service types, such as + `Effect<{ readonly id: number }, NetworkError | DecodeError | HttpError.With, never>`, + instead of `CallEffect<…>` and `CallError<…>` with the schemas. The types + are unchanged; only how editors print them. A `CallEffect` you write + yourself prints the same way once it's used in a program. + ## 0.1.0-rc.0 ### Added diff --git a/README.md b/README.md index 182313e..63daccb 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> ``` > **Release candidate.** effetch is close to its first stable release diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index cdb23fe..ef971ee 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,20 @@ 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 }); `; const config = ts.getParsedCommandLineOfConfigFile( @@ -145,6 +162,25 @@ expect( `Hover on an endpoint doesn't name its type and route: ${endpointHover}`, ); +for (const [marker, members] of [ + ["todoCall", ["NetworkError", "DecodeError", "HttpError.With<"]], + ["todoProgram", ["NetworkError", "DecodeError", "HttpError.With<"]], + ["endpointCall", ["NetworkError", "DecodeError", "HttpError.With<"]], + ["timedCall", ["NetworkError", "DecodeError", "TimeoutError"]], +]) { + const hover = ts.displayPartsToString( + service.getQuickInfoAtPosition(probe, at(marker))?.displayParts, + ); + expect( + hover.includes(": Effect.Effect<") && + members.every((member) => hover.includes(member)) && + !["CallEffect<", "CallError<", "CallServices<"].some((alias) => + hover.includes(alias), + ), + `Hover on ${marker} doesn't show Effect.Effect<…> with ${members.join(", ")}: ${hover}`, + ); +} + const queryStart = source.lastIndexOf("\n", at("query")) + 1; const queryEnd = source.indexOf("\n", at("query")); const queryErrors = service diff --git a/src/Endpoint.ts b/src/Endpoint.ts index d0c067f..4be4be4 100644 --- a/src/Endpoint.ts +++ b/src/Endpoint.ts @@ -1,12 +1,14 @@ +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 type { AnySchema, - CallEffect, ParamsOption, QueryInput, QueryOf, + Success, } from "./Options.js"; import type { Plugin } from "./Plugin.js"; @@ -285,7 +287,17 @@ 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"`. */ diff --git a/src/Options.ts b/src/Options.ts index 4979712..4185498 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -315,7 +315,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 @@ -379,7 +379,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 +389,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 +435,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 +485,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 +537,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/test/types/client.test-d.ts b/test/types/client.test-d.ts index af09ba0..af630ab 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", () => { diff --git a/test/types/endpoint.test-d.ts b/test/types/endpoint.test-d.ts index fb4508e..c226958 100644 --- a/test/types/endpoint.test-d.ts +++ b/test/types/endpoint.test-d.ts @@ -21,6 +21,13 @@ import type { 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")<{}> {} @@ -296,6 +303,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", () => { From 84142979a962426b52584cb58bca320bde711901 Mon Sep 17 00:00:00 2001 From: monarcode Date: Fri, 30 Oct 2026 13:54:29 +0100 Subject: [PATCH 2/9] docs(types): document CallEffect inference change and guard the written-out call types --- AGENTS.md | 6 ++- CHANGELOG.md | 20 ++++++--- README.md | 6 ++- scripts/editor-check.mjs | 84 ++++++++++++++++++++++++++++++----- src/Options.ts | 4 ++ test/types/client.test-d.ts | 54 ++++++++++++++++++++++ test/types/endpoint.test-d.ts | 43 ++++++++++++++++++ 7 files changed, 197 insertions(+), 20 deletions(-) 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 849d160..0a5385c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,22 @@ All notable changes to `@monarcode/effetch`, newest first. ## 0.1.0-rc.1 -### Fixed +### Changed -- Hovering a call, or a program built from calls, shows its success, error - and service types, such as - `Effect<{ readonly id: number }, NetworkError | DecodeError | HttpError.With, never>`, +- 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 unchanged; only how editors print them. A `CallEffect` you write - yourself prints the same way once it's used in a program. + 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. ## 0.1.0-rc.0 diff --git a/README.md b/README.md index 63daccb..56bab2f 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 | DecodeError | HttpError.With> +// NetworkError | DecodeError | HttpError.With, never> ``` > **Release candidate.** effetch is close to its first stable release @@ -1293,7 +1293,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/scripts/editor-check.mjs b/scripts/editor-check.mjs index ef971ee..7ba6b00 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -44,6 +44,25 @@ export const /*todoProgram*/todoProgram = Effect.gen(function* () { 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, +}); + +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( @@ -162,22 +181,65 @@ expect( `Hover on an endpoint doesn't name its type and route: ${endpointHover}`, ); -for (const [marker, members] of [ - ["todoCall", ["NetworkError", "DecodeError", "HttpError.With<"]], - ["todoProgram", ["NetworkError", "DecodeError", "HttpError.With<"]], - ["endpointCall", ["NetworkError", "DecodeError", "HttpError.With<"]], - ["timedCall", ["NetworkError", "DecodeError", "TimeoutError"]], +// 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"]], + ["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( - hover.includes(": Effect.Effect<") && - members.every((member) => hover.includes(member)) && - !["CallEffect<", "CallError<", "CallServices<"].some((alias) => - hover.includes(alias), - ), - `Hover on ${marker} doesn't show Effect.Effect<…> with ${members.join(", ")}: ${hover}`, + 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}`, ); } diff --git a/src/Options.ts b/src/Options.ts index 4185498..efd5ac6 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -368,6 +368,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"; diff --git a/test/types/client.test-d.ts b/test/types/client.test-d.ts index af630ab..7de3dff 100644 --- a/test/types/client.test-d.ts +++ b/test/types/client.test-d.ts @@ -333,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 c226958..9c88d85 100644 --- a/test/types/endpoint.test-d.ts +++ b/test/types/endpoint.test-d.ts @@ -358,6 +358,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", () => { From 409241474241403b9f689ef4de92acbd3386a1ff Mon Sep 17 00:00:00 2001 From: monarcode Date: Fri, 30 Oct 2026 14:00:44 +0100 Subject: [PATCH 3/9] feat(endpoint): expose output, error and input schemas --- CHANGELOG.md | 10 ++++++++++ README.md | 6 +++++- scripts/smoke.sh | 7 +++++++ src/Client.ts | 2 +- src/Endpoint.ts | 13 ++++++++++++- src/internal/endpoint.ts | 20 +++++++++++++++++++- test/endpoint.test.ts | 31 +++++++++++++++++++++++++++++++ test/types/endpoint.test-d.ts | 32 ++++++++++++++++++++++++++++++++ 8 files changed, 117 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5385c..dc64e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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. + ### Changed - Calls and endpoints return `Effect.Effect<…>` with their errors and diff --git a/README.md b/README.md index 56bab2f..59df6cf 100644 --- a/README.md +++ b/README.md @@ -934,7 +934,11 @@ What's fixed goes in the definition, and what changes goes in each 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 diff --git a/scripts/smoke.sh b/scripts/smoke.sh index b10ad32..d1b36ea 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -214,6 +214,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)); diff --git a/src/Client.ts b/src/Client.ts index 0525347..c1e4d9b 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -502,7 +502,7 @@ export const make = < patch: call("PATCH"), del: call("DELETE"), endpoint: (route: string, definition?: RuntimeDefinition) => - makeEndpoint(call, route, definition), + makeEndpoint(call, config.defaultError, route, definition), }; return client as unknown as Client; }); diff --git a/src/Endpoint.ts b/src/Endpoint.ts index 4be4be4..04644c7 100644 --- a/src/Endpoint.ts +++ b/src/Endpoint.ts @@ -255,7 +255,8 @@ type Requirements< * 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` @@ -272,6 +273,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 } }); * }; * ``` @@ -304,6 +306,15 @@ export interface Endpoint< 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; } /** diff --git a/src/internal/endpoint.ts b/src/internal/endpoint.ts index be7c24f..6cf7402 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,6 +22,12 @@ 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; } /** @@ -52,9 +59,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 => { @@ -76,5 +87,12 @@ export const makeEndpoint = ( : Headers.merge(headers, Headers.fromInput(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, + }); }; diff --git a/test/endpoint.test.ts b/test/endpoint.test.ts index 42c50c4..fb1ee7f 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)]); diff --git a/test/types/endpoint.test-d.ts b/test/types/endpoint.test-d.ts index 9c88d85..0a13af1 100644 --- a/test/types/endpoint.test-d.ts +++ b/test/types/endpoint.test-d.ts @@ -125,6 +125,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) }); From 4f1a28d0e1963e99b8821bd8ead35fd18389b4d1 Mon Sep 17 00:00:00 2001 From: monarcode Date: Fri, 30 Oct 2026 14:22:04 +0100 Subject: [PATCH 4/9] feat(endpoint): define endpoints without a client and bind them Fetch.endpoint(route, definition) returns frozen data (route, method, path, schemas, headers) that can live at module level. client.bind turns it into the same Endpoint client.endpoint makes. Plugin overrides are given when binding; a definition without `error` uses the client's defaultError. Adds UnboundEndpoint, UnboundEndpointDefinition, BindMethod and BindOptions. The example app's todos module now defines its endpoints in contract/ and binds them in data/. --- CHANGELOG.md | 12 + README.md | 133 ++++++++-- examples/README.md | 23 +- examples/app/modules/todos/contract/todos.ts | 21 +- examples/app/modules/todos/data/todo.api.ts | 27 +- scripts/editor-check.mjs | 55 ++++ scripts/smoke.sh | 43 +++ src/Client.ts | 67 ++++- src/Endpoint.ts | 266 ++++++++++++++++++- src/index.ts | 5 + src/internal/endpoint.ts | 76 +++++- test/endpoint.test.ts | 165 ++++++++++++ test/exports.test.ts | 1 + test/types/endpoint.test-d.ts | 129 +++++++++ 14 files changed, 967 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc64e25..73d5da3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ All notable changes to `@monarcode/effetch`, newest first. `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`, + `UnboundEndpointDefinition`, `BindMethod` and `BindOptions`. Values typed as + `Client` that you build by hand need `bind` too. ### Changed diff --git a/README.md b/README.md index 59df6cf..30234b5 100644 --- a/README.md +++ b/README.md @@ -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) @@ -906,6 +907,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,11 +986,13 @@ 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 @@ -989,17 +1052,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 @@ -1067,8 +1135,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/ @@ -1076,8 +1144,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/ @@ -1087,8 +1155,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"; @@ -1110,15 +1178,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< @@ -1147,9 +1224,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. @@ -1160,8 +1238,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 @@ -1288,6 +1370,9 @@ page's URL. - `Endpoint`, `EndpointMethod`, `EndpointDefinition`, `EndpointOptions`, `EndpointBody`, `EndpointArgs`, `EndpointRoute`, `ValidRoute`, `RouteMethod`, `RoutePath`: [endpoints](#endpoints). +- `endpoint`, `UnboundEndpoint`, `UnboundEndpointDefinition`, `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`: 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/scripts/editor-check.mjs b/scripts/editor-check.mjs index 7ba6b00..544f013 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -54,6 +54,14 @@ export const /*requestCall*/requestCall = typed.request("PATCH", "/todos/:id", { 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>; @@ -118,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) => @@ -137,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(", ")}`, ); @@ -164,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 ?? [] @@ -173,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, ); @@ -181,6 +227,14 @@ 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 = []; @@ -226,6 +280,7 @@ 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"]], diff --git a/scripts/smoke.sh b/scripts/smoke.sh index d1b36ea..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; @@ -235,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 c1e4d9b..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; } /** @@ -503,6 +562,10 @@ export const make = < del: call("DELETE"), endpoint: (route: string, definition?: RuntimeDefinition) => 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 04644c7..b90ca3c 100644 --- a/src/Endpoint.ts +++ b/src/Endpoint.ts @@ -3,6 +3,11 @@ 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, ParamsOption, @@ -250,9 +255,9 @@ 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, and its `output`, `error` and `input` schemas, for @@ -352,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.UnboundEndpointDefinition< + * "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 UnboundEndpointDefinition< + 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?: UnboundEndpointDefinition, +): UnboundEndpoint; +export function endpoint( + route: string, + definition?: RuntimeUnboundDefinition, +): RuntimeUnboundEndpoint { + return makeUnboundEndpoint(route, definition); +} diff --git a/src/index.ts b/src/index.ts index cd49bbc..e69848d 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, + UnboundEndpoint, + UnboundEndpointDefinition, ValidRoute, } from "./Endpoint.js"; export { diff --git a/src/internal/endpoint.ts b/src/internal/endpoint.ts index 6cf7402..385923c 100644 --- a/src/internal/endpoint.ts +++ b/src/internal/endpoint.ts @@ -30,6 +30,35 @@ export interface RuntimeEndpoint { 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 @@ -81,10 +110,7 @@ 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, { @@ -96,3 +122,45 @@ export const makeEndpoint = ( 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/test/endpoint.test.ts b/test/endpoint.test.ts index fb1ee7f..3e18232 100644 --- a/test/endpoint.test.ts +++ b/test/endpoint.test.ts @@ -421,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/types/endpoint.test-d.ts b/test/types/endpoint.test-d.ts index 0a13af1..04651c4 100644 --- a/test/types/endpoint.test-d.ts +++ b/test/types/endpoint.test-d.ts @@ -11,13 +11,16 @@ 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"; @@ -39,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 }); @@ -451,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> + >(); + }); +}); From 7233ca511812de6c22ebfdfd7a39e0748e2d0c7f Mon Sep 17 00:00:00 2001 From: monarcode Date: Fri, 30 Oct 2026 14:26:00 +0100 Subject: [PATCH 5/9] refactor(endpoint)!: rename `UnboundEndpointDefinition` to `UnboundDefinition` - rename the exported type in `Endpoint.ts` and the `index.ts` export list - update the README API list and the changelog entry to match --- CHANGELOG.md | 2 +- README.md | 2 +- src/Endpoint.ts | 6 +++--- src/index.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d5da3..c56378b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ All notable changes to `@monarcode/effetch`, newest first. 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`, - `UnboundEndpointDefinition`, `BindMethod` and `BindOptions`. Values typed as + `UnboundDefinition`, `BindMethod` and `BindOptions`. Values typed as `Client` that you build by hand need `bind` too. ### Changed diff --git a/README.md b/README.md index 30234b5..333a9be 100644 --- a/README.md +++ b/README.md @@ -1370,7 +1370,7 @@ page's URL. - `Endpoint`, `EndpointMethod`, `EndpointDefinition`, `EndpointOptions`, `EndpointBody`, `EndpointArgs`, `EndpointRoute`, `ValidRoute`, `RouteMethod`, `RoutePath`: [endpoints](#endpoints). -- `endpoint`, `UnboundEndpoint`, `UnboundEndpointDefinition`, `BindMethod`, +- `endpoint`, `UnboundEndpoint`, `UnboundDefinition`, `BindMethod`, `BindOptions`: [endpoints without a client](#defining-endpoints-without-a-client). - `NetworkError`, `HttpError` (and `HttpError.With`), `DecodeError`, diff --git a/src/Endpoint.ts b/src/Endpoint.ts index b90ca3c..315f093 100644 --- a/src/Endpoint.ts +++ b/src/Endpoint.ts @@ -388,7 +388,7 @@ export interface EndpointMethod< * const Todo = Schema.Struct({ id: Schema.Finite, done: Schema.Boolean }); * const TodoPatch = Schema.Struct({ done: Schema.Boolean }); * - * const update: Fetch.UnboundEndpointDefinition< + * const update: Fetch.UnboundDefinition< * "PATCH /todos/:id", * typeof Todo, * undefined, @@ -402,7 +402,7 @@ export interface EndpointMethod< * * @since 0.1.0 */ -export interface UnboundEndpointDefinition< +export interface UnboundDefinition< R extends string, O extends AnySchema | undefined, EB extends AnySchema | undefined, @@ -604,7 +604,7 @@ export function endpoint< I extends AnySchema | undefined = undefined, >( route: ValidRoute, - definition?: UnboundEndpointDefinition, + definition?: UnboundDefinition, ): UnboundEndpoint; export function endpoint( route: string, diff --git a/src/index.ts b/src/index.ts index e69848d..7613b1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,8 +13,8 @@ export type { EndpointRoute, RouteMethod, RoutePath, + UnboundDefinition, UnboundEndpoint, - UnboundEndpointDefinition, ValidRoute, } from "./Endpoint.js"; export { From c64c967a5fb0fb41c4ed0e81411a75fd5f836e82 Mon Sep 17 00:00:00 2001 From: monarcode Date: Fri, 30 Oct 2026 14:41:36 +0100 Subject: [PATCH 6/9] chore(release): bump version to 0.1.0-rc.1 --- CHANGELOG.md | 11 +++++------ package.json | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c56378b..67a8e4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,11 @@ All notable changes to `@monarcode/effetch`, newest first. 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`, + 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. diff --git a/package.json b/package.json index c53305d..53e966d 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", From 68da6de8ac7ec28d3519f553a245d1e0478c554f Mon Sep 17 00:00:00 2001 From: monarcode Date: Fri, 30 Oct 2026 14:49:03 +0100 Subject: [PATCH 7/9] build(deps): bump effect and @effect/vitest to 4.1.0, @effect/language-service to 0.87.5 --- package.json | 6 +++--- pnpm-lock.yaml | 34 +++++++++++++++++----------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 53e966d..943840f 100644 --- a/package.json +++ b/package.json @@ -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: {} From 9b4527001d2f53a7eb7aa5ac7ec324e96d1afec3 Mon Sep 17 00:00:00 2001 From: monarcode Date: Fri, 30 Oct 2026 14:49:23 +0100 Subject: [PATCH 8/9] fix(query): send Redacted query values as their value --- CHANGELOG.md | 7 +++++++ README.md | 4 +++- src/Options.ts | 4 +++- src/internal/query.ts | 18 +++++++++++++++--- test/client.test.ts | 20 ++++++++++++++++++++ test/query.test.ts | 9 +++++++++ 6 files changed, 57 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a8e4b..994c563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,13 @@ All notable changes to `@monarcode/effetch`, newest first. 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 333a9be..934b78a 100644 --- a/README.md +++ b/README.md @@ -371,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 diff --git a/src/Options.ts b/src/Options.ts index efd5ac6..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 diff --git a/src/internal/query.ts b/src/internal/query.ts index db97269..e2edbf3 100644 --- a/src/internal/query.ts +++ b/src/internal/query.ts @@ -1,7 +1,19 @@ 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()`. + */ +const toParam = (value: unknown): Param => + Redacted.isRedacted(value) ? (value as Param) : String(value); + /** * Converts the `query` option into `UrlParams`, in key order. Arrays repeat * the key; `null` and `undefined` values and array items are dropped. @@ -12,17 +24,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..2d7ba0d 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -10,6 +10,7 @@ import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { describe, expect } from "vitest"; @@ -168,6 +169,25 @@ describe("path params and query", () => { }), ); + it.effect("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.make("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/query.test.ts b/test/query.test.ts index f3559eb..fa547bd 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -1,4 +1,5 @@ import * as UrlParams from "effect/http/UrlParams"; +import * as Redacted from "effect/Redacted"; import { describe, expect, it } from "vitest"; @@ -62,6 +63,14 @@ describe("toUrlParams", () => { expect(entries({ tags: [] })).toEqual([]); }); + it("keeps Redacted values, and serialises them as their value", () => { + const token = Redacted.make("s3cret"); + const params = toUrlParams({ token, ids: [Redacted.make("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( From d8fcca96b909513516d4435454f8c3959651cab6 Mon Sep 17 00:00:00 2001 From: monarcode Date: Fri, 30 Oct 2026 16:11:41 +0100 Subject: [PATCH 9/9] fix(query): keep the Redacted query change compiling with Effect 4.0 The oldest-supported-effect CI job typechecks with effect 4.0.0, where UrlParams values are strings. Cast the whole conditional in toParam so it compiles with both versions, and run the Redacted query tests only when the installed Effect sends Redacted values as their value. --- src/internal/query.ts | 6 +++++- test/client.test.ts | 40 ++++++++++++++++++++++------------------ test/query.test.ts | 19 +++++++++++-------- test/utils/redacted.ts | 20 ++++++++++++++++++++ 4 files changed, 58 insertions(+), 27 deletions(-) create mode 100644 test/utils/redacted.ts diff --git a/src/internal/query.ts b/src/internal/query.ts index e2edbf3..165e5ee 100644 --- a/src/internal/query.ts +++ b/src/internal/query.ts @@ -10,9 +10,13 @@ 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 as Param) : String(value); + (Redacted.isRedacted(value) ? value : String(value)) as Param; /** * Converts the `query` option into `UrlParams`, in key order. Arrays repeat diff --git a/test/client.test.ts b/test/client.test.ts index 2d7ba0d..233e944 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -10,7 +10,6 @@ import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as Layer from "effect/Layer"; -import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { describe, expect } from "vitest"; @@ -24,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 }); @@ -169,23 +169,27 @@ describe("path params and query", () => { }), ); - it.effect("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.make("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.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", () => diff --git a/test/query.test.ts b/test/query.test.ts index fa547bd..df7bdc2 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -1,9 +1,9 @@ import * as UrlParams from "effect/http/UrlParams"; -import * as Redacted from "effect/Redacted"; 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), @@ -63,13 +63,16 @@ describe("toUrlParams", () => { expect(entries({ tags: [] })).toEqual([]); }); - it("keeps Redacted values, and serialises them as their value", () => { - const token = Redacted.make("s3cret"); - const params = toUrlParams({ token, ids: [Redacted.make("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.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( 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";