Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Q>` 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
Expand Down
39 changes: 22 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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(),
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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 `<redacted>`.
- `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
Expand All @@ -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) =>
Expand Down Expand Up @@ -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<string> = [];

Expand Down Expand Up @@ -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`:

Expand Down
4 changes: 2 additions & 2 deletions examples/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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, Effect.Success<typeof make>>()("Api") {}
Expand Down
4 changes: 2 additions & 2 deletions examples/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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(),
Expand Down
4 changes: 2 additions & 2 deletions examples/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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],
});

Expand Down
33 changes: 33 additions & 0 deletions scripts/editor-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion scripts/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 13 additions & 13 deletions src/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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],
* });
* ```
Expand All @@ -73,8 +73,8 @@ export interface ClientConfig<
readonly baseUrl?: string | Effect.Effect<string, BaseE, BaseR> | 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;
}
Expand All @@ -85,15 +85,15 @@ 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
* import * as Effect from "effect/Effect";
* 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(
Expand Down Expand Up @@ -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", {
Expand All @@ -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", {
Expand Down Expand Up @@ -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", {
Expand All @@ -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", {
Expand All @@ -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", {
Expand Down Expand Up @@ -413,7 +413,7 @@ export const make = <
applyDefaults,
);
const call = makeCall({
errorSchema: config.error,
errorSchema: config.defaultError,
applyDefaults,
chain,
plugins,
Expand Down
7 changes: 4 additions & 3 deletions src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export class NetworkError extends Schema.TaggedError<NetworkError>()(
* sensitive ones (`set-cookie`, `authorization`, …) print as
* `<redacted>` 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.
Expand Down Expand Up @@ -83,7 +84,7 @@ export class NetworkError extends Schema.TaggedError<NetworkError>()(
export class HttpError extends Schema.TaggedError<HttpError>()("HttpError", {
method: Schema.String,
url: Schema.String,
status: Schema.Number,
status: Schema.Finite,
headers: Schema.declare(Headers.isHeaders),
text: Schema.String,
body: Schema.Unknown,
Expand Down Expand Up @@ -161,7 +162,7 @@ export class DecodeError extends Schema.TaggedError<DecodeError>()(
{
method: Schema.String,
url: Schema.String,
status: Schema.Number,
status: Schema.Finite,
issue: Schema.Unknown,
message: Schema.String,
},
Expand Down
Loading
Loading