diff --git a/CHANGELOG.md b/CHANGELOG.md index 005df7b..604e15c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to `@monarcode/effetch`, newest first. +## 0.1.0-alpha.2 + +### Changed + +- `make`'s `error` option is now `defaultError`, so it reads differently from + a call's `error`, which replaces it. + +### Fixed + +- `query` now accepts values typed with an `interface`. Each property is + still checked, so a `Date`, a nested object or a function is rejected. The + new `QueryOf` type is the constraint; `QueryInput` is unchanged, and the + option types (`BaseOptions`, `NoBodyRequestOptions`, `RequestOptions`) take + an optional trailing `Q` parameter that defaults to it. +- `HttpError.status` and `DecodeError.status` use `Schema.Finite` instead of + `Schema.Number`. The value is still a `number`, as before. + +### Documentation + +- Examples use `Schema.Finite` instead of `Schema.Number`. Through the JSON + codec, `Schema.Number` decodes `"NaN"`, `"Infinity"` and `"-Infinity"` + strings into numbers and encodes them back as strings. + ## 0.1.0-alpha.1 First release published from CI with npm provenance. No changes to the diff --git a/README.md b/README.md index 544e5f0..1fb519e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ import type * as Fetch from "@monarcode/effetch"; declare const api: Fetch.Client; -const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }); +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 }, @@ -98,7 +98,7 @@ import * as Schema from "effect/Schema"; import * as Fetch from "@monarcode/effetch"; const Todo = Schema.Struct({ - id: Schema.Number, + id: Schema.Finite, title: Schema.String, completed: Schema.Boolean, }); @@ -141,7 +141,7 @@ const session: { accessToken?: string } = {}; const make = Fetch.make({ baseUrl: Config.String("API_URL"), headers: { "x-client": "web" }, - error: ApiError, + defaultError: ApiError, plugins: [ logger(), retry(), @@ -159,12 +159,12 @@ export const ApiLive = Layer.effect(Api)(make).pipe( ); ``` -| Option | Description | -| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseUrl` | Prepended to every path that isn't an absolute `http(s)://` URL. A string, or an Effect run once when the client is made (a `Config` works). It can't contain a query string or a fragment. | -| `headers` | Sent with every request. A call's header of the same name wins. `content-type` and `content-length` are ignored here: they come from each body. | -| `error` | The default schema for non-2xx response bodies (see [Errors](#errors)). | -| `plugins` | See [Plugins](#plugins). The first one is the outermost. | +| Option | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUrl` | Prepended to every path that isn't an absolute `http(s)://` URL. A string, or an Effect run once when the client is made (a `Config` works). It can't contain a query string or a fragment. | +| `headers` | Sent with every request. A call's header of the same name wins. `content-type` and `content-length` are ignored here: they come from each body. | +| `defaultError` | The schema for non-2xx response bodies of calls without their own `error` (see [Errors](#errors)). | +| `plugins` | See [Plugins](#plugins). The first one is the outermost. | The client holds on to the `HttpClient`, the `baseUrl` and the services its plugins need. Its calls need nothing from the environment, apart from the @@ -181,7 +181,7 @@ import type * as Fetch from "@monarcode/effetch"; declare const api: Fetch.Client; const Todo = Schema.Struct({ - id: Schema.Number, + id: Schema.Finite, title: Schema.String, done: Schema.Boolean, due: Schema.Date, @@ -237,6 +237,11 @@ options)` do. An empty 2xx body (204, `HEAD`) decodes as JSON `null`: use `Schema.Void` for endpoints that return nothing. +Use `Schema.Finite` rather than `Schema.Number` for numbers. Schemas go through +Effect's JSON codec, where `Schema.Number` also decodes the strings `"NaN"`, +`"Infinity"` and `"-Infinity"`, and encodes those values back to strings. +`Schema.Finite` accepts every JSON number and rejects the rest. + `client.httpClient` is the configured `HttpClient` underneath: base URL, default headers and plugins, but no status check and no decoding. Use it for anything the methods don't cover, such as streaming a response. @@ -255,7 +260,7 @@ anything the methods don't cover, such as streaming a response. - `HttpError.headers` are Effect `Headers`: `authorization`, `set-cookie` and other sensitive headers print as ``. - `HttpError.text` is the raw body. `body` is the body decoded with the - `error` schema, `undefined` if it didn't match, or the parsed JSON when + call's `error` schema (or the client's `defaultError`), `undefined` if it didn't match, or the parsed JSON when there's no schema. Reading it never fails the call. ```ts @@ -266,7 +271,7 @@ import type * as Fetch from "@monarcode/effetch"; declare const api: Fetch.Client; -const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }); +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); const ApiError = Schema.Struct({ code: Schema.String }); export const todo = (id: number) => @@ -480,7 +485,7 @@ import * as Schema from "effect/Schema"; import * as Fetch from "@monarcode/effetch"; -const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }); +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); const sent: Array = []; @@ -571,10 +576,10 @@ page's URL. [writing plugins](#writing-a-plugin). - Option and result types, for wrapping the client in your own functions: `RequestOptions`, `NoBodyRequestOptions`, `BaseOptions`, `BodyOptions`, - `ParamsOption`, `QueryInput`, `QueryValue`, `AnySchema`, `Args`, - `Success`, `CallError`, `CallServices`, `CallEffect`, `NoBodyMethod`, - `BodyMethod`, `RequestMethod`, `PathParamNames`, `PathParams`, - `PathParamValue`. + `ParamsOption`, `QueryInput`, `QueryOf`, `QueryValue`, `AnySchema`, + `Args`, `Success`, `CallError`, `CallServices`, `CallEffect`, + `NoBodyMethod`, `BodyMethod`, `RequestMethod`, `PathParamNames`, + `PathParams`, `PathParamValue`. `@monarcode/effetch/plugins`: diff --git a/examples/basic.ts b/examples/basic.ts index 7be0635..266aec1 100644 --- a/examples/basic.ts +++ b/examples/basic.ts @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema"; import * as Fetch from "@monarcode/effetch"; const Todo = Schema.Struct({ - id: Schema.Number, + id: Schema.Finite, title: Schema.String, done: Schema.Boolean, }); @@ -19,7 +19,7 @@ const ApiError = Schema.Struct({ code: Schema.String, message: Schema.String }); const make = Fetch.make({ baseUrl: "https://api.example.com", headers: { accept: "application/json" }, - error: ApiError, + defaultError: ApiError, }); class Api extends Context.Service>()("Api") {} diff --git a/examples/builtins.ts b/examples/builtins.ts index 244b4df..ae7eaae 100644 --- a/examples/builtins.ts +++ b/examples/builtins.ts @@ -8,7 +8,7 @@ import * as Fetch from "@monarcode/effetch"; import { bearer, logger, retry, timeout } from "@monarcode/effetch/plugins"; const Todo = Schema.Struct({ - id: Schema.Number, + id: Schema.Finite, title: Schema.String, done: Schema.Boolean, }); @@ -23,7 +23,7 @@ const session: { accessToken: string | undefined } = { export const make = Fetch.make({ baseUrl: "https://api.example.com", - error: ApiError, + defaultError: ApiError, plugins: [ logger({ level: "Info" }), retry(), diff --git a/examples/plugins.ts b/examples/plugins.ts index f59deb8..8f763b8 100644 --- a/examples/plugins.ts +++ b/examples/plugins.ts @@ -12,7 +12,7 @@ import * as Schema from "effect/Schema"; import * as Fetch from "@monarcode/effetch"; const Todo = Schema.Struct({ - id: Schema.Number, + id: Schema.Finite, title: Schema.String, done: Schema.Boolean, }); @@ -129,7 +129,7 @@ const reportErrors = Fetch.plugin({ export const make = Fetch.make({ baseUrl: "https://api.example.com", - error: ApiError, + defaultError: ApiError, plugins: [reportErrors, deadline, apiVersion, auth], }); diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index d59ec2a..b4f3903 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -15,6 +15,14 @@ export const make = Fetch./*make*/make({ declare const api: Fetch.Client; export const call = api./*get*/get("/users/:id", { params: { /*params*/ } }); + +interface ListUsersParams { + readonly limit: number; + readonly page?: number | undefined; +} +declare const listUsersParams: ListUsersParams; + +export const list = api./*query*/get("/users", { query: listUsersParams }); `; const config = ts.getParsedCommandLineOfConfigFile( @@ -102,6 +110,31 @@ expect( `params on "/users/:id" doesn't complete id: ${names.slice(0, 10).join(", ")}`, ); +const queryStart = source.lastIndexOf("\n", at("query")) + 1; +const queryEnd = source.indexOf("\n", at("query")); +const queryErrors = service + .getSemanticDiagnostics(probe) + .filter( + (diagnostic) => + diagnostic.start !== undefined && + diagnostic.start >= queryStart && + diagnostic.start < queryEnd, + ) + .map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, " "), + ); +expect( + queryErrors.length === 0, + `An interface-typed query doesn't compile: ${queryErrors.join("; ")}`, +); +const queryHover = ts.displayPartsToString( + service.getQuickInfoAtPosition(probe, at("query"))?.displayParts, +); +expect( + queryHover.includes(", ListUsersParams>") && !queryHover.includes("QueryOf"), + `Hover on get with a query doesn't name the query's type: ${queryHover}`, +); + if (failures.length > 0) { console.error(failures.map((failure) => `✗ ${failure}`).join("\n")); process.exit(1); diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 0793b42..d1d4d60 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -108,7 +108,7 @@ if (timedOut._tag !== "TimeoutError" || Fetch.isFetchError(timedOut)) { throw new Error("TimeoutError from @monarcode/effetch/plugins is wrong"); } -const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }); +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); const requestId = Fetch.plugin({ name: "requestId", diff --git a/src/Client.ts b/src/Client.ts index 3534ad9..2dc7f37 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -30,8 +30,8 @@ import type { Plugin } from "./Plugin.js"; * `content-type` on a call to override it). When a call has an `output` * schema, effetch sends `Accept: application/json` unless an `accept` * header is set here or on the call. - * - `error`: the default schema for non-2xx response bodies. A call's own - * `error` option replaces it. + * - `defaultError`: the schema for non-2xx response bodies of calls that + * don't pass their own `error` option. A call's `error` replaces it. * - `plugins`: hooks into every call (see {@link Plugin}). `plugins[0]` is * the outermost layer: its `onRequest` runs first and its `onResponse` * last. Names must be unique. Pass the list inline: annotating it as @@ -56,7 +56,7 @@ import type { Plugin } from "./Plugin.js"; * const make = Fetch.make({ * baseUrl: Config.String("API_URL"), * headers: { accept: "application/json" }, - * error: ApiError, + * defaultError: ApiError, * plugins: [apiKey], * }); * ``` @@ -73,8 +73,8 @@ export interface ClientConfig< readonly baseUrl?: string | Effect.Effect | undefined; /** Headers sent with every request. */ readonly headers?: Headers.Input | undefined; - /** Default schema for non-2xx response bodies. */ - readonly error?: EB | undefined; + /** Schema for non-2xx response bodies, unless a call passes its own `error`. */ + readonly defaultError?: EB | undefined; /** Plugins, outermost first. Names must be unique. */ readonly plugins?: Ps | undefined; } @@ -85,7 +85,7 @@ export interface ClientConfig< * can fail with the plugins' errors on top of effetch's own. * * - `Ps`: the plugins the client was made with. - * - `EB`: the client's default `error` schema. + * - `EB`: the client's `defaultError` schema. * * @example * ```ts @@ -93,7 +93,7 @@ export interface ClientConfig< * import * as Schema from "effect/Schema"; * import * as Fetch from "@monarcode/effetch"; * - * const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }); + * const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); * * const program = (api: Fetch.Client) => * api.get("/todos/:id", { params: { id: 1 }, output: Todo }).pipe( @@ -147,7 +147,7 @@ export interface Client< * import * as Schema from "effect/Schema"; * import * as Fetch from "@monarcode/effetch"; * - * const Todo = Schema.Struct({ id: Schema.Number, done: Schema.Boolean }); + * const Todo = Schema.Struct({ id: Schema.Finite, done: Schema.Boolean }); * * const complete = (api: Fetch.Client, id: number) => * api.request("PATCH", "/todos/:id", { @@ -167,7 +167,7 @@ export interface Client< * import * as Schema from "effect/Schema"; * import * as Fetch from "@monarcode/effetch"; * - * const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }); + * const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); * * const list = (api: Fetch.Client) => * api.get("/todos", { @@ -223,7 +223,7 @@ export interface Client< * import * as Fetch from "@monarcode/effetch"; * * const NewTodo = Schema.Struct({ title: Schema.String, due: Schema.Date }); - * const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }); + * const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); * * const create = (api: Fetch.Client) => * api.post("/todos", { @@ -250,7 +250,7 @@ export interface Client< * import * as Schema from "effect/Schema"; * import * as Fetch from "@monarcode/effetch"; * - * const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }); + * const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); * * const replace = (api: Fetch.Client, todo: typeof Todo.Type) => * api.put("/todos/:id", { @@ -272,7 +272,7 @@ export interface Client< * import * as Schema from "effect/Schema"; * import * as Fetch from "@monarcode/effetch"; * - * const Todo = Schema.Struct({ id: Schema.Number, done: Schema.Boolean }); + * const Todo = Schema.Struct({ id: Schema.Finite, done: Schema.Boolean }); * * const complete = (api: Fetch.Client, id: number) => * api.patch("/todos/:id", { @@ -413,7 +413,7 @@ export const make = < applyDefaults, ); const call = makeCall({ - errorSchema: config.error, + errorSchema: config.defaultError, applyDefaults, chain, plugins, diff --git a/src/Errors.ts b/src/Errors.ts index 53b169d..598706e 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -51,7 +51,8 @@ export class NetworkError extends Schema.TaggedError()( * sensitive ones (`set-cookie`, `authorization`, …) print as * `` when the error is logged or serialised. * - `text` is always the raw response body (`""` if it couldn't be read). - * - `body` is the body decoded with the call's (or client's) `error` schema, + * - `body` is the body decoded with the call's `error` schema (or the client's + * `defaultError`), * or `undefined` if it didn't match. Without an `error` schema, `body` is * the parsed JSON when `text` is valid JSON, else `undefined`. An empty * body is always `undefined`. Reading `body` never fails the call. @@ -83,7 +84,7 @@ export class NetworkError extends Schema.TaggedError()( export class HttpError extends Schema.TaggedError()("HttpError", { method: Schema.String, url: Schema.String, - status: Schema.Number, + status: Schema.Finite, headers: Schema.declare(Headers.isHeaders), text: Schema.String, body: Schema.Unknown, @@ -161,7 +162,7 @@ export class DecodeError extends Schema.TaggedError()( { method: Schema.String, url: Schema.String, - status: Schema.Number, + status: Schema.Finite, issue: Schema.Unknown, message: Schema.String, }, diff --git a/src/Options.ts b/src/Options.ts index 5f1fe35..f17ebf4 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -48,6 +48,34 @@ export type QueryInput = { readonly [key: string]: QueryValue | ReadonlyArray; }; +/** + * The constraint on a `query` of type `Q`: each of `Q`'s own properties must + * be a {@link QueryValue} or an array of them. + * + * Unlike {@link QueryInput}, this has no index signature, so it also accepts + * values typed with an `interface` (TypeScript never gives interfaces an + * implicit index signature). + * + * @example + * ```ts + * import type * as Fetch from "@monarcode/effetch"; + * + * interface ListTodosParams { + * readonly limit: number; + * readonly tags?: ReadonlyArray | undefined; + * } + * + * declare const params: ListTodosParams; + * + * const query: Fetch.QueryOf = params; + * ``` + * + * @since 0.1.0 + */ +export type QueryOf = { + readonly [K in keyof Q]: QueryValue | ReadonlyArray; +}; + /** * Any Effect schema accepted by the `output`, `error` and `input` options. * @@ -56,7 +84,7 @@ export type QueryInput = { * import * as Schema from "effect/Schema"; * import type * as Fetch from "@monarcode/effetch"; * - * const Todo: Fetch.AnySchema = Schema.Struct({ id: Schema.Number }); + * const Todo: Fetch.AnySchema = Schema.Struct({ id: Schema.Finite }); * ``` * * @since 0.1.0 @@ -96,13 +124,15 @@ export type ParamsOption

= string extends P * - `O`: the `output` schema, or `undefined` for the raw response. * - `EB`: the `error` schema for non-2xx bodies. * - `Ps`: the client's plugins, for the `plugins` override map. + * - `Q`: the type of `query`, checked with {@link QueryOf}. Defaults to + * {@link QueryInput}. * * @example * ```ts * import * as Schema from "effect/Schema"; * import type * as Fetch from "@monarcode/effetch"; * - * const Todo = Schema.Struct({ id: Schema.Number }); + * const Todo = Schema.Struct({ id: Schema.Finite }); * * const options: Fetch.BaseOptions = { * query: { expand: "owner" }, @@ -117,9 +147,10 @@ export interface BaseOptions< O extends AnySchema | undefined, EB extends AnySchema, Ps extends ReadonlyArray, + Q extends QueryOf = QueryInput, > { /** Query parameters. Arrays repeat the key; `null`/`undefined` are dropped. */ - readonly query?: QueryInput | undefined; + readonly query?: Q | undefined; /** Headers for this call, merged over the client's headers. */ readonly headers?: Headers.Input | undefined; /** @@ -198,8 +229,9 @@ export type NoBodyRequestOptions< O extends AnySchema | undefined, EB extends AnySchema, Ps extends ReadonlyArray, + Q extends QueryOf = QueryInput, > = ParamsOption

& - BaseOptions & { + BaseOptions & { readonly body?: undefined; readonly input?: undefined; }; @@ -226,7 +258,8 @@ export type RequestOptions< EB extends AnySchema, I extends AnySchema, Ps extends ReadonlyArray, -> = ParamsOption

& BaseOptions & BodyOptions; + Q extends QueryOf = QueryInput, +> = ParamsOption

& BaseOptions & BodyOptions; /** * The rest arguments after the path: the options object `T` is required @@ -257,7 +290,7 @@ export type Args

= [PathParamNames

] extends [never] * import * as Schema from "effect/Schema"; * import type * as Fetch from "@monarcode/effetch"; * - * type Todo = Fetch.Success>; + * type Todo = Fetch.Success>; * // ^? { readonly id: number } * ``` * @@ -278,7 +311,7 @@ export type Success = O extends AnySchema * import type * as Fetch from "@monarcode/effetch"; * * type E = Fetch.CallError< - * Schema.Struct<{ id: Schema.Number }>, + * Schema.Struct<{ id: Schema.Finite }>, * Schema.Struct<{ code: Schema.String }>, * [] * >; @@ -307,7 +340,7 @@ export type CallError< * import type * as Fetch from "@monarcode/effetch"; * * type R = Fetch.CallServices< - * Schema.Struct<{ id: Schema.Number }>, + * Schema.Struct<{ id: Schema.Finite }>, * Schema.Unknown, * Schema.Unknown * >; @@ -340,7 +373,7 @@ export type CallServices< * import type * as Fetch from "@monarcode/effetch"; * * type Call = Fetch.CallEffect< - * Schema.Struct<{ id: Schema.Number }>, + * Schema.Struct<{ id: Schema.Finite }>, * Schema.Unknown, * Schema.Unknown, * [] @@ -361,7 +394,8 @@ export type CallEffect< * The signature of `get`, `head` and `options`: no `body` or `input`. * * - `Ps`: the client's plugins. - * - `DefaultEB`: the client's `error` schema, used when a call has none. + * - `DefaultEB`: the client's `defaultError` schema, used when a call has + * no `error`. * * @example * ```ts @@ -370,7 +404,7 @@ export type CallEffect< * * declare const get: Fetch.NoBodyMethod<[], Schema.Unknown>; * - * const Todo = Schema.Struct({ id: Schema.Number }); + * const Todo = Schema.Struct({ id: Schema.Finite }); * const todo = get("/todos/:id", { params: { id: 1 }, output: Todo }); * ``` * @@ -384,9 +418,10 @@ export interface NoBodyMethod< P extends string, O extends AnySchema | undefined = undefined, EB extends AnySchema = DefaultEB, + Q extends QueryOf = QueryInput, >( path: P, - ...args: Args> + ...args: Args> ): CallEffect; } @@ -395,7 +430,8 @@ export interface NoBodyMethod< * typed by `input` when given. * * - `Ps`: the client's plugins. - * - `DefaultEB`: the client's `error` schema, used when a call has none. + * - `DefaultEB`: the client's `defaultError` schema, used when a call has + * no `error`. * * @example * ```ts @@ -404,7 +440,7 @@ export interface NoBodyMethod< * * declare const patch: Fetch.BodyMethod<[], Schema.Unknown>; * - * const Todo = Schema.Struct({ id: Schema.Number, done: Schema.Boolean }); + * const Todo = Schema.Struct({ id: Schema.Finite, done: Schema.Boolean }); * const updated = patch("/todos/:id", { * params: { id: 1 }, * body: { done: true }, @@ -423,9 +459,10 @@ export interface BodyMethod< O extends AnySchema | undefined = undefined, EB extends AnySchema = DefaultEB, I extends AnySchema = Schema.Unknown, + Q extends QueryOf = QueryInput, >( path: P, - ...args: Args> + ...args: Args> ): CallEffect; } @@ -434,7 +471,8 @@ export interface BodyMethod< * `input` when given. * * - `Ps`: the client's plugins. - * - `DefaultEB`: the client's `error` schema, used when a call has none. + * - `DefaultEB`: the client's `defaultError` schema, used when a call has + * no `error`. * * @example * ```ts @@ -443,7 +481,7 @@ export interface BodyMethod< * * declare const request: Fetch.RequestMethod<[], Schema.Unknown>; * - * const Todo = Schema.Struct({ id: Schema.Number, done: Schema.Boolean }); + * const Todo = Schema.Struct({ id: Schema.Finite, done: Schema.Boolean }); * const updated = request("PATCH", "/todos/:id", { * params: { id: 1 }, * body: { done: true }, @@ -462,9 +500,10 @@ export interface RequestMethod< O extends AnySchema | undefined = undefined, EB extends AnySchema = DefaultEB, I extends AnySchema = Schema.Unknown, + Q extends QueryOf = QueryInput, >( method: HttpMethod.HttpMethod, path: P, - ...args: Args> + ...args: Args> ): CallEffect; } diff --git a/src/index.ts b/src/index.ts index cba98b5..a4353f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ export type { NoBodyRequestOptions, ParamsOption, QueryInput, + QueryOf, QueryValue, RequestMethod, RequestOptions, diff --git a/src/internal/body.ts b/src/internal/body.ts index 1707246..78b1b03 100644 --- a/src/internal/body.ts +++ b/src/internal/body.ts @@ -118,7 +118,7 @@ export const encodeBody = ( I["EncodingServices"] > => { if (body === undefined) { - return Effect.succeed(undefined); + return Effect.undefined; } if (input !== undefined) { return orDieInvalid(HttpBody.jsonSchema(input)(body)); diff --git a/src/internal/response.ts b/src/internal/response.ts index 244d1ae..718193e 100644 --- a/src/internal/response.ts +++ b/src/internal/response.ts @@ -28,7 +28,7 @@ const readErrorBody = ( try { json = parseBody(text); } catch { - return Effect.succeed(undefined); + return Effect.undefined; } if (json === undefined || schema === undefined) { return Effect.succeed(json); @@ -88,16 +88,14 @@ export function checkStatus( return Effect.gen(function* () { const text = yield* Effect.orElseSucceed(response.text, () => ""); const body = yield* readErrorBody(text, errorSchema); - return yield* Effect.fail( - new HttpError({ - method: response.request.method, - url: errorUrl(response.request.url), - status: response.status, - headers: response.headers, - text, - body, - }), - ); + return yield* new HttpError({ + method: response.request.method, + url: errorUrl(response.request.url), + status: response.status, + headers: response.headers, + text, + body, + }); }); } diff --git a/test/client.test.ts b/test/client.test.ts index 355119f..24debfe 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -25,7 +25,7 @@ import { } from "./utils/mockClient.js"; import { reply } from "./utils/reply.js"; -const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }); +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); const NewTodo = Schema.Struct({ title: Schema.String, due: Schema.Date }); const ApiError = Schema.Struct({ code: Schema.String }); @@ -155,6 +155,19 @@ describe("path params and query", () => { }), ); + it.effect("accept a query typed with an interface", () => + Effect.gen(function* () { + interface ListTodosParams { + readonly limit: number; + readonly page?: number | undefined; + } + const params: ListTodosParams = { limit: 5, page: undefined }; + const { mock, api } = yield* setup([reply.json({})]); + yield* api.get("/todos", { query: params }); + expect(wire(mock).url).toBe("https://api.test/todos?limit=5"); + }), + ); + it.effect("make the call defect when a param is missing at runtime", () => Effect.gen(function* () { const { mock, api } = yield* setup([reply.json({})]); @@ -469,7 +482,7 @@ describe("results", () => { reply.json({ code: "gone" }, { status: 410 }), reply.json({ message: "other" }, { status: 400 }), ], - { error: ApiError }, + { defaultError: ApiError }, ); const first = yield* Effect.flip(api.get("/todos/1")); const second = yield* Effect.flip(api.get("/todos/1")); @@ -484,7 +497,7 @@ describe("results", () => { Effect.gen(function* () { const { api } = yield* setup( [reply.json({ reason: "full" }, { status: 409 })], - { error: ApiError }, + { defaultError: ApiError }, ); const Conflict = Schema.Struct({ reason: Schema.String }); const error = yield* Effect.flip( @@ -524,9 +537,10 @@ describe("an HttpClient that fails on non-2xx statuses", () => { const failing = Layer.effect(HttpClient.HttpClient)( Effect.map(HttpClient.HttpClient, HttpClient.filterStatusOk), ).pipe(Layer.provide(mock.layer)); - return yield* Fetch.make({ baseUrl: base, error: ApiError }).pipe( - Effect.provide(failing), - ); + return yield* Fetch.make({ + baseUrl: base, + defaultError: ApiError, + }).pipe(Effect.provide(failing)); }); it.effect("still gives an HttpError with the response body", () => diff --git a/test/plugins.test.ts b/test/plugins.test.ts index 3ac0666..57b9e83 100644 --- a/test/plugins.test.ts +++ b/test/plugins.test.ts @@ -18,7 +18,7 @@ import { captureLogs } from "./utils/logs.js"; import { failure, hang, type Script } from "./utils/mockClient.js"; import { reply, respond } from "./utils/reply.js"; -const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }); +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); class SessionExpired extends Data.TaggedError("SessionExpired")<{}> {} class Unavailable extends Data.TaggedError("Unavailable")<{ diff --git a/test/plugins/retry.test.ts b/test/plugins/retry.test.ts index 065097d..9e3a205 100644 --- a/test/plugins/retry.test.ts +++ b/test/plugins/retry.test.ts @@ -22,7 +22,7 @@ import { captureLogs } from "../utils/logs.js"; import { delayed, failure, hang } from "../utils/mockClient.js"; import { reply } from "../utils/reply.js"; -const Todo = Schema.Struct({ id: Schema.Number }); +const Todo = Schema.Struct({ id: Schema.Finite }); const everySecond = Schedule.spaced("1 second"); diff --git a/test/plugins/timeout.test.ts b/test/plugins/timeout.test.ts index a139190..e0db637 100644 --- a/test/plugins/timeout.test.ts +++ b/test/plugins/timeout.test.ts @@ -40,7 +40,7 @@ describe("timeout", () => { plugins: [timeout({ duration: "5 seconds" })], }); const value = yield* after( - api.get("/slow", { output: Schema.Number }), + api.get("/slow", { output: Schema.Finite }), "4 seconds", ); expect(value).toBe(1); @@ -54,7 +54,7 @@ describe("timeout", () => { }); const value = yield* after( api.get("/export", { - output: Schema.Number, + output: Schema.Finite, plugins: { timeout: { duration: "10 seconds" } }, }), "8 seconds", @@ -77,7 +77,7 @@ describe("timeout", () => { }); const value = yield* after( api.get("/slow", { - output: Schema.Number, + output: Schema.Finite, plugins: { timeout: false }, }), "1 minute", diff --git a/test/response.test.ts b/test/response.test.ts index 9deafba..704fc23 100644 --- a/test/response.test.ts +++ b/test/response.test.ts @@ -35,7 +35,7 @@ const withSecretQuery = [ ), ]; -const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }); +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); const ApiError = Schema.Struct({ code: Schema.String }); class Locale extends Context.Service()( diff --git a/test/types/client.test-d.ts b/test/types/client.test-d.ts index 51b46ee..af09ba0 100644 --- a/test/types/client.test-d.ts +++ b/test/types/client.test-d.ts @@ -11,7 +11,13 @@ import * as SchemaGetter from "effect/SchemaGetter"; import { describe, expectTypeOf, it } from "vitest"; import type { DecodeError, HttpError, NetworkError } from "../../src/Errors.js"; -import type { BodyMethod, NoBodyMethod } from "../../src/Options.js"; +import type { + BaseOptions, + BodyMethod, + NoBodyMethod, + QueryInput, + RequestMethod, +} from "../../src/Options.js"; import type { Plugin } from "../../src/Plugin.js"; class TimeoutError extends Data.TaggedError("TimeoutError")<{}> {} @@ -24,8 +30,9 @@ type Ps = [ declare const get: NoBodyMethod; declare const post: BodyMethod; +declare const request: RequestMethod; -const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }); +const Todo = Schema.Struct({ id: Schema.Finite, title: Schema.String }); const ApiError = Schema.Struct({ code: Schema.String }); const NewTodo = Schema.Struct({ title: Schema.String, due: Schema.Date }); @@ -95,6 +102,99 @@ describe("query and headers", () => { }); }); +interface ListTodosParams { + readonly limit: number; + readonly page?: number; + readonly after?: string | undefined; + readonly tags?: ReadonlyArray | undefined; + readonly q: string | null; +} +type ListTodosAlias = { + readonly limit: number; + readonly page?: number; +}; +interface WithDate extends ListTodosParams { + readonly when: Date; +} +interface WithFunction { + readonly limit: number; + readonly next: () => number; +} + +declare const params: ListTodosParams; +declare const alias: ListTodosAlias; +declare const withDate: WithDate; +declare const withFunction: WithFunction; +declare const maybe: ListTodosParams | undefined; +declare const input: QueryInput; + +describe("query", () => { + it("accepts values typed with an interface", () => { + get("/todos", { query: params }); + get("/todos/:id", { params: { id: 1 }, query: params }); + post("/todos", { query: params, body: {} }); + request("GET", "/todos", { query: params }); + }); + + it("accepts type aliases, object literals and QueryInput", () => { + get("/todos", { query: alias }); + get("/todos", { query: { limit: 5, page: 2 } }); + get("/todos", { query: input }); + }); + + it("accepts optional properties, nullish values and readonly arrays", () => { + get("/todos", { query: { limit: 5 } satisfies ListTodosAlias }); + get("/todos", { + query: { + limit: 5, + after: undefined, + q: null, + tags: ["a", "b"] as ReadonlyArray, + } satisfies ListTodosParams, + }); + get("/todos", { query: maybe }); + }); + + it("can be omitted", () => { + get("/todos"); + get("/todos", {}); + get("/todos", { query: undefined }); + }); + + it("rejects values that aren't query values", () => { + // @ts-expect-error a Date isn't a query value + get("/todos", { query: { when: new Date() } }); + // @ts-expect-error a function isn't a query value + get("/todos", { query: { next: () => 1 } }); + // @ts-expect-error arrays of objects aren't query values + get("/todos", { query: { ids: [{ id: 1 }] } }); + }); + + it("rejects interfaces with a property of the wrong type", () => { + // @ts-expect-error when is a Date + get("/todos", { query: withDate }); + // @ts-expect-error next is a function + post("/todos", { query: withFunction }); + // @ts-expect-error when is a Date + request("GET", "/todos", { query: withDate }); + }); + + it("falls back to QueryInput with explicit type arguments", () => { + get<"/todos", typeof Todo>("/todos", { query: { limit: 5 } }); + // @ts-expect-error still rejects non-query values + get<"/todos", typeof Todo>("/todos", { query: { when: new Date() } }); + }); + + it("is QueryInput in the option types by default", () => { + expectTypeOf< + BaseOptions["query"] + >().toEqualTypeOf(); + expectTypeOf< + BaseOptions["query"] + >().toEqualTypeOf(); + }); +}); + describe("body", () => { it("is not available on get", () => { get("/todos", { body: undefined, input: undefined }); diff --git a/test/types/make.test-d.ts b/test/types/make.test-d.ts index 4e6436b..341ef57 100644 --- a/test/types/make.test-d.ts +++ b/test/types/make.test-d.ts @@ -14,7 +14,7 @@ import { describe, expectTypeOf, it } from "vitest"; import * as Fetch from "../../src/index.js"; const ApiError = Schema.Struct({ code: Schema.String }); -const Todo = Schema.Struct({ id: Schema.Number }); +const Todo = Schema.Struct({ id: Schema.Finite }); class Origin extends Context.Service()("Origin") {} class Session extends Context.Service()("Session") {} @@ -70,13 +70,15 @@ describe("make", () => { }); it("types the client's error schema", () => { - expectTypeOf(Fetch.make({ error: ApiError })).toEqualTypeOf< + expectTypeOf(Fetch.make({ defaultError: ApiError })).toEqualTypeOf< Effect.Effect< Fetch.Client<[], typeof ApiError>, never, HttpClient.HttpClient > >(); + // @ts-expect-error the client's option is defaultError + Fetch.make({ error: ApiError }); }); it("carries the baseUrl Effect's error and services", () => { diff --git a/test/types/plugin.test-d.ts b/test/types/plugin.test-d.ts index 9b75f7b..4bf88a6 100644 --- a/test/types/plugin.test-d.ts +++ b/test/types/plugin.test-d.ts @@ -39,7 +39,7 @@ const timeout = plugin({ Effect.gen(function* () { const { duration } = yield* options; if (duration === "0 seconds") { - return yield* Effect.fail(new TimeoutError()); + return yield* new TimeoutError(); } return yield* response; }), diff --git a/test/types/response.test-d.ts b/test/types/response.test-d.ts index 6eb290d..8bbc153 100644 --- a/test/types/response.test-d.ts +++ b/test/types/response.test-d.ts @@ -9,7 +9,7 @@ import { checkStatus, decodeOutput } from "../../src/internal/response.js"; declare const response: HttpClientResponse.HttpClientResponse; -const Todo = Schema.Struct({ id: Schema.Number }); +const Todo = Schema.Struct({ id: Schema.Finite }); const ApiError = Schema.Struct({ code: Schema.String }); interface Locale {