@monarcode/effetch 0.0.0-stage → 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +613 -2
- package/dist/Client.d.ts +367 -0
- package/dist/Client.js +110 -0
- package/dist/Errors.d.ts +224 -0
- package/dist/Errors.js +151 -0
- package/dist/Options.d.ts +462 -0
- package/dist/Path.d.ts +72 -0
- package/dist/Plugin.d.ts +553 -0
- package/dist/Plugin.js +7 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +5 -0
- package/dist/internal/body.js +70 -0
- package/dist/internal/object.js +6 -0
- package/dist/internal/path.js +23 -0
- package/dist/internal/pipeline.js +29 -0
- package/dist/internal/plugins.js +92 -0
- package/dist/internal/query.js +22 -0
- package/dist/internal/request.js +33 -0
- package/dist/internal/resolve.js +23 -0
- package/dist/internal/response.js +73 -0
- package/dist/internal/url.js +24 -0
- package/dist/plugins/bearer.d.ts +152 -0
- package/dist/plugins/bearer.js +63 -0
- package/dist/plugins/index.d.ts +32 -0
- package/dist/plugins/index.js +6 -0
- package/dist/plugins/logger.d.ts +66 -0
- package/dist/plugins/logger.js +67 -0
- package/dist/plugins/retry.d.ts +94 -0
- package/dist/plugins/retry.js +74 -0
- package/dist/plugins/timeout.d.ts +108 -0
- package/dist/plugins/timeout.js +84 -0
- package/package.json +85 -3
package/dist/Client.d.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Headers from "effect/http/Headers";
|
|
3
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
|
|
6
|
+
import { NetworkError } from "./Errors.js";
|
|
7
|
+
import {
|
|
8
|
+
AnySchema,
|
|
9
|
+
BodyMethod,
|
|
10
|
+
NoBodyMethod,
|
|
11
|
+
RequestMethod,
|
|
12
|
+
} from "./Options.js";
|
|
13
|
+
import { Plugin } from "./Plugin.js";
|
|
14
|
+
//#region src/Client.d.ts
|
|
15
|
+
/**
|
|
16
|
+
* Configuration for {@link make}.
|
|
17
|
+
*
|
|
18
|
+
* - `baseUrl`: prepended to every path that isn't already an absolute
|
|
19
|
+
* `http://` or `https://` URL. A string, or an Effect (a `Config` works)
|
|
20
|
+
* that's run once, when the client is made. It can't contain a query
|
|
21
|
+
* string or a fragment: pass those with each call's `query` option.
|
|
22
|
+
* - `headers`: sent with every request. A header passed to a call replaces
|
|
23
|
+
* the client's one of the same name. `content-type` and `content-length`
|
|
24
|
+
* are ignored here: they come from each request's body (set
|
|
25
|
+
* `content-type` on a call to override it). When a call has an `output`
|
|
26
|
+
* schema, effetch sends `Accept: application/json` unless an `accept`
|
|
27
|
+
* header is set here or on the call.
|
|
28
|
+
* - `error`: the default schema for non-2xx response bodies. A call's own
|
|
29
|
+
* `error` option replaces it.
|
|
30
|
+
* - `plugins`: hooks into every call (see {@link Plugin}). `plugins[0]` is
|
|
31
|
+
* the outermost layer: its `onRequest` runs first and its `onResponse`
|
|
32
|
+
* last. Names must be unique. Pass the list inline: annotating it as
|
|
33
|
+
* `Plugin.Any[]` erases the plugins' types.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* import * as Config from "effect/Config";
|
|
38
|
+
* import * as Effect from "effect/Effect";
|
|
39
|
+
* import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
40
|
+
* import * as Schema from "effect/Schema";
|
|
41
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
42
|
+
*
|
|
43
|
+
* const ApiError = Schema.Struct({ code: Schema.String });
|
|
44
|
+
*
|
|
45
|
+
* const apiKey = Fetch.plugin({
|
|
46
|
+
* name: "apiKey",
|
|
47
|
+
* onRequest: (request) =>
|
|
48
|
+
* Effect.succeed(HttpClientRequest.setHeader(request, "x-api-key", "key")),
|
|
49
|
+
* });
|
|
50
|
+
*
|
|
51
|
+
* const make = Fetch.make({
|
|
52
|
+
* baseUrl: Config.String("API_URL"),
|
|
53
|
+
* headers: { accept: "application/json" },
|
|
54
|
+
* error: ApiError,
|
|
55
|
+
* plugins: [apiKey],
|
|
56
|
+
* });
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* @since 0.1.0
|
|
60
|
+
*/
|
|
61
|
+
export interface ClientConfig<
|
|
62
|
+
Ps extends ReadonlyArray<Plugin.Any> = [],
|
|
63
|
+
EB extends AnySchema = Schema.Unknown,
|
|
64
|
+
BaseE = never,
|
|
65
|
+
BaseR = never,
|
|
66
|
+
> {
|
|
67
|
+
/** Prepended to relative paths. A string, or an Effect run once by `make`. */
|
|
68
|
+
readonly baseUrl?: string | Effect.Effect<string, BaseE, BaseR> | undefined;
|
|
69
|
+
/** Headers sent with every request. */
|
|
70
|
+
readonly headers?: Headers.Input | undefined;
|
|
71
|
+
/** Default schema for non-2xx response bodies. */
|
|
72
|
+
readonly error?: EB | undefined;
|
|
73
|
+
/** Plugins, outermost first. Names must be unique. */
|
|
74
|
+
readonly plugins?: Ps | undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A client made by {@link make}. Every call returns an Effect that needs
|
|
78
|
+
* no services, apart from those the call's own schemas require, and that
|
|
79
|
+
* can fail with the plugins' errors on top of effetch's own.
|
|
80
|
+
*
|
|
81
|
+
* - `Ps`: the plugins the client was made with.
|
|
82
|
+
* - `EB`: the client's default `error` schema.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* import * as Effect from "effect/Effect";
|
|
87
|
+
* import * as Schema from "effect/Schema";
|
|
88
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
89
|
+
*
|
|
90
|
+
* const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String });
|
|
91
|
+
*
|
|
92
|
+
* const program = (api: Fetch.Client) =>
|
|
93
|
+
* api.get("/todos/:id", { params: { id: 1 }, output: Todo }).pipe(
|
|
94
|
+
* Effect.catchTag("HttpError", (error) =>
|
|
95
|
+
* error.status === 404 ? Effect.succeed(undefined) : Effect.fail(error),
|
|
96
|
+
* ),
|
|
97
|
+
* );
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* @since 0.1.0
|
|
101
|
+
*/
|
|
102
|
+
export interface Client<
|
|
103
|
+
Ps extends ReadonlyArray<Plugin.Any> = [],
|
|
104
|
+
EB extends AnySchema = Schema.Unknown,
|
|
105
|
+
> {
|
|
106
|
+
/**
|
|
107
|
+
* The configured `HttpClient` underneath. It adds the base URL and the
|
|
108
|
+
* default headers to the requests you give it and runs every plugin
|
|
109
|
+
* with its defaults, but does no status check, no decoding and no
|
|
110
|
+
* `onError` hooks.
|
|
111
|
+
*
|
|
112
|
+
* Transport failures are `NetworkError`s, next to the plugins' own
|
|
113
|
+
* errors. Use it for anything the methods don't cover, such as
|
|
114
|
+
* streaming a response.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* import * as Effect from "effect/Effect";
|
|
119
|
+
* import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
120
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
121
|
+
*
|
|
122
|
+
* const download = (api: Fetch.Client) =>
|
|
123
|
+
* Effect.flatMap(
|
|
124
|
+
* api.httpClient.execute(
|
|
125
|
+
* HttpClientRequest.get("https://api.example.com/export.csv"),
|
|
126
|
+
* ),
|
|
127
|
+
* (response) => response.text,
|
|
128
|
+
* );
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
readonly httpClient: HttpClient.HttpClient.With<
|
|
132
|
+
NetworkError | Plugin.Errors<Ps>
|
|
133
|
+
>;
|
|
134
|
+
/**
|
|
135
|
+
* Sends a request with any HTTP method. The other methods are shorthand
|
|
136
|
+
* for this one.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```ts
|
|
140
|
+
* import * as Schema from "effect/Schema";
|
|
141
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
142
|
+
*
|
|
143
|
+
* const Todo = Schema.Struct({ id: Schema.Number, done: Schema.Boolean });
|
|
144
|
+
*
|
|
145
|
+
* const complete = (api: Fetch.Client, id: number) =>
|
|
146
|
+
* api.request("PATCH", "/todos/:id", {
|
|
147
|
+
* params: { id },
|
|
148
|
+
* body: { done: true },
|
|
149
|
+
* output: Todo,
|
|
150
|
+
* });
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
readonly request: RequestMethod<Ps, EB>;
|
|
154
|
+
/**
|
|
155
|
+
* Sends a `GET` request. Takes no body.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```ts
|
|
159
|
+
* import * as Schema from "effect/Schema";
|
|
160
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
161
|
+
*
|
|
162
|
+
* const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String });
|
|
163
|
+
*
|
|
164
|
+
* const list = (api: Fetch.Client) =>
|
|
165
|
+
* api.get("/todos", {
|
|
166
|
+
* query: { page: 1, tags: ["work", "urgent"] },
|
|
167
|
+
* output: Schema.Array(Todo),
|
|
168
|
+
* });
|
|
169
|
+
* // GET /todos?page=1&tags=work&tags=urgent
|
|
170
|
+
*
|
|
171
|
+
* const one = (api: Fetch.Client, id: number) =>
|
|
172
|
+
* api.get("/todos/:id", { params: { id }, output: Todo });
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
readonly get: NoBodyMethod<Ps, EB>;
|
|
176
|
+
/**
|
|
177
|
+
* Sends a `HEAD` request. Takes no body.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```ts
|
|
181
|
+
* import * as Effect from "effect/Effect";
|
|
182
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
183
|
+
*
|
|
184
|
+
* const exists = (api: Fetch.Client, id: number) =>
|
|
185
|
+
* api.head("/todos/:id", { params: { id } }).pipe(
|
|
186
|
+
* Effect.as(true),
|
|
187
|
+
* Effect.catchTag("HttpError", () => Effect.succeed(false)),
|
|
188
|
+
* );
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
readonly head: NoBodyMethod<Ps, EB>;
|
|
192
|
+
/**
|
|
193
|
+
* Sends an `OPTIONS` request. Takes no body.
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* ```ts
|
|
197
|
+
* import * as Effect from "effect/Effect";
|
|
198
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
199
|
+
*
|
|
200
|
+
* const allowed = (api: Fetch.Client) =>
|
|
201
|
+
* Effect.map(api.options("/todos"), (response) => response.headers["allow"]);
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
readonly options: NoBodyMethod<Ps, EB>;
|
|
205
|
+
/**
|
|
206
|
+
* Sends a `POST` request. `body` is sent according to its type, or
|
|
207
|
+
* encoded with `input` when given.
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* ```ts
|
|
211
|
+
* import * as Schema from "effect/Schema";
|
|
212
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
213
|
+
*
|
|
214
|
+
* const NewTodo = Schema.Struct({ title: Schema.String, due: Schema.Date });
|
|
215
|
+
* const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String });
|
|
216
|
+
*
|
|
217
|
+
* const create = (api: Fetch.Client) =>
|
|
218
|
+
* api.post("/todos", {
|
|
219
|
+
* input: NewTodo,
|
|
220
|
+
* body: { title: "Write docs", due: new Date() },
|
|
221
|
+
* output: Todo,
|
|
222
|
+
* });
|
|
223
|
+
*
|
|
224
|
+
* const upload = (api: Fetch.Client, file: Blob) => {
|
|
225
|
+
* const form = new FormData();
|
|
226
|
+
* form.append("file", file);
|
|
227
|
+
* return api.post("/upload", { body: form });
|
|
228
|
+
* };
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
readonly post: BodyMethod<Ps, EB>;
|
|
232
|
+
/**
|
|
233
|
+
* Sends a `PUT` request. `body` is sent according to its type, or
|
|
234
|
+
* encoded with `input` when given.
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* ```ts
|
|
238
|
+
* import * as Schema from "effect/Schema";
|
|
239
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
240
|
+
*
|
|
241
|
+
* const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String });
|
|
242
|
+
*
|
|
243
|
+
* const replace = (api: Fetch.Client, todo: typeof Todo.Type) =>
|
|
244
|
+
* api.put("/todos/:id", {
|
|
245
|
+
* params: { id: todo.id },
|
|
246
|
+
* input: Todo,
|
|
247
|
+
* body: todo,
|
|
248
|
+
* output: Todo,
|
|
249
|
+
* });
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
readonly put: BodyMethod<Ps, EB>;
|
|
253
|
+
/**
|
|
254
|
+
* Sends a `PATCH` request. `body` is sent according to its type, or
|
|
255
|
+
* encoded with `input` when given.
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* ```ts
|
|
259
|
+
* import * as Schema from "effect/Schema";
|
|
260
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
261
|
+
*
|
|
262
|
+
* const Todo = Schema.Struct({ id: Schema.Number, done: Schema.Boolean });
|
|
263
|
+
*
|
|
264
|
+
* const complete = (api: Fetch.Client, id: number) =>
|
|
265
|
+
* api.patch("/todos/:id", {
|
|
266
|
+
* params: { id },
|
|
267
|
+
* body: { done: true },
|
|
268
|
+
* output: Todo,
|
|
269
|
+
* });
|
|
270
|
+
* ```
|
|
271
|
+
*/
|
|
272
|
+
readonly patch: BodyMethod<Ps, EB>;
|
|
273
|
+
/**
|
|
274
|
+
* Sends a `DELETE` request. Named `del` because `delete` is a reserved
|
|
275
|
+
* word.
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ```ts
|
|
279
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
280
|
+
*
|
|
281
|
+
* const remove = (api: Fetch.Client, id: number) =>
|
|
282
|
+
* api.del("/todos/:id", { params: { id } });
|
|
283
|
+
* ```
|
|
284
|
+
*/
|
|
285
|
+
readonly del: BodyMethod<Ps, EB>;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Makes a {@link Client}.
|
|
289
|
+
*
|
|
290
|
+
* `make` is an Effect: it needs an `HttpClient` (for example
|
|
291
|
+
* `FetchHttpClient.layer`, or a mock in tests), whatever the `baseUrl`
|
|
292
|
+
* Effect needs, and the services the plugins need. The client holds on to
|
|
293
|
+
* all of them, so its calls need nothing from the environment apart from
|
|
294
|
+
* the services of their own schemas.
|
|
295
|
+
*
|
|
296
|
+
* Calls run with the services and references `make` had, plus those of
|
|
297
|
+
* the calling fiber, which win: a reference provided around one call (such
|
|
298
|
+
* as `FetchHttpClient.RequestInit`) overrides the one `make` saw.
|
|
299
|
+
*
|
|
300
|
+
* Calls fail with `NetworkError` when no response arrives, `HttpError` for
|
|
301
|
+
* a non-2xx status, `DecodeError` when `output` doesn't match, and with
|
|
302
|
+
* the plugins' own errors.
|
|
303
|
+
*
|
|
304
|
+
* Invalid input is a defect, not a typed error, because it's a mistake in
|
|
305
|
+
* the calling code: two plugins with the same name, a missing path param,
|
|
306
|
+
* a body that can't be encoded, a body on a `GET` or `HEAD` request, a
|
|
307
|
+
* `baseUrl` with a query string or a fragment, and, outside browsers, a
|
|
308
|
+
* relative URL without a `baseUrl` (browsers resolve it against the page's
|
|
309
|
+
* URL).
|
|
310
|
+
*
|
|
311
|
+
* An `HttpClient` that fails on non-2xx statuses (`filterStatusOk`) works
|
|
312
|
+
* too: effetch takes the response back and reports the usual `HttpError`.
|
|
313
|
+
*
|
|
314
|
+
* **Browsers and tracing headers.** Effect's `HttpClient` adds `traceparent`
|
|
315
|
+
* and `b3` headers to every request. In a browser, those make cross-origin
|
|
316
|
+
* calls to APIs that don't allow them fail the CORS preflight. Turn that
|
|
317
|
+
* off where you run the program, or only around `make`, which then applies
|
|
318
|
+
* it to all of the client's calls:
|
|
319
|
+
*
|
|
320
|
+
* ```ts
|
|
321
|
+
* import * as Effect from "effect/Effect";
|
|
322
|
+
* import * as HttpClient from "effect/http/HttpClient";
|
|
323
|
+
*
|
|
324
|
+
* declare const program: Effect.Effect<void>;
|
|
325
|
+
*
|
|
326
|
+
* const main = Effect.provideService(
|
|
327
|
+
* program,
|
|
328
|
+
* HttpClient.TracerPropagationEnabled,
|
|
329
|
+
* false,
|
|
330
|
+
* );
|
|
331
|
+
* ```
|
|
332
|
+
*
|
|
333
|
+
* @example
|
|
334
|
+
* ```ts
|
|
335
|
+
* import * as Context from "effect/Context";
|
|
336
|
+
* import * as Effect from "effect/Effect";
|
|
337
|
+
* import * as Layer from "effect/Layer";
|
|
338
|
+
* import * as FetchHttpClient from "effect/http/FetchHttpClient";
|
|
339
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
340
|
+
*
|
|
341
|
+
* const make = Fetch.make({
|
|
342
|
+
* baseUrl: "https://api.example.com",
|
|
343
|
+
* headers: { accept: "application/json" },
|
|
344
|
+
* });
|
|
345
|
+
*
|
|
346
|
+
* class Api extends Context.Service<Api, Effect.Success<typeof make>>()("Api") {}
|
|
347
|
+
*
|
|
348
|
+
* const ApiLive = Layer.effect(Api)(make).pipe(
|
|
349
|
+
* Layer.provide(FetchHttpClient.layer),
|
|
350
|
+
* );
|
|
351
|
+
* ```
|
|
352
|
+
*
|
|
353
|
+
* @since 0.1.0
|
|
354
|
+
*/
|
|
355
|
+
export declare const make: <
|
|
356
|
+
const Ps extends ReadonlyArray<Plugin.Any> = [],
|
|
357
|
+
EB extends AnySchema = Schema.Unknown,
|
|
358
|
+
BaseE = never,
|
|
359
|
+
BaseR = never,
|
|
360
|
+
>(
|
|
361
|
+
config?: ClientConfig<Ps, EB, BaseE, BaseR>,
|
|
362
|
+
) => Effect.Effect<
|
|
363
|
+
Client<Ps, EB>,
|
|
364
|
+
BaseE,
|
|
365
|
+
HttpClient.HttpClient | BaseR | Plugin.Services<Ps>
|
|
366
|
+
>;
|
|
367
|
+
//#endregion
|
package/dist/Client.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { hasQueryOrFragment } from "./internal/url.js";
|
|
2
|
+
import { makeApplyDefaults, mapClientErrors } from "./internal/pipeline.js";
|
|
3
|
+
import { applyPlugins, provideOverrides } from "./internal/plugins.js";
|
|
4
|
+
import { makeCall } from "./internal/request.js";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import * as Headers from "effect/http/Headers";
|
|
7
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
8
|
+
|
|
9
|
+
//#region src/Client.ts
|
|
10
|
+
/**
|
|
11
|
+
* Makes a {@link Client}.
|
|
12
|
+
*
|
|
13
|
+
* `make` is an Effect: it needs an `HttpClient` (for example
|
|
14
|
+
* `FetchHttpClient.layer`, or a mock in tests), whatever the `baseUrl`
|
|
15
|
+
* Effect needs, and the services the plugins need. The client holds on to
|
|
16
|
+
* all of them, so its calls need nothing from the environment apart from
|
|
17
|
+
* the services of their own schemas.
|
|
18
|
+
*
|
|
19
|
+
* Calls run with the services and references `make` had, plus those of
|
|
20
|
+
* the calling fiber, which win: a reference provided around one call (such
|
|
21
|
+
* as `FetchHttpClient.RequestInit`) overrides the one `make` saw.
|
|
22
|
+
*
|
|
23
|
+
* Calls fail with `NetworkError` when no response arrives, `HttpError` for
|
|
24
|
+
* a non-2xx status, `DecodeError` when `output` doesn't match, and with
|
|
25
|
+
* the plugins' own errors.
|
|
26
|
+
*
|
|
27
|
+
* Invalid input is a defect, not a typed error, because it's a mistake in
|
|
28
|
+
* the calling code: two plugins with the same name, a missing path param,
|
|
29
|
+
* a body that can't be encoded, a body on a `GET` or `HEAD` request, a
|
|
30
|
+
* `baseUrl` with a query string or a fragment, and, outside browsers, a
|
|
31
|
+
* relative URL without a `baseUrl` (browsers resolve it against the page's
|
|
32
|
+
* URL).
|
|
33
|
+
*
|
|
34
|
+
* An `HttpClient` that fails on non-2xx statuses (`filterStatusOk`) works
|
|
35
|
+
* too: effetch takes the response back and reports the usual `HttpError`.
|
|
36
|
+
*
|
|
37
|
+
* **Browsers and tracing headers.** Effect's `HttpClient` adds `traceparent`
|
|
38
|
+
* and `b3` headers to every request. In a browser, those make cross-origin
|
|
39
|
+
* calls to APIs that don't allow them fail the CORS preflight. Turn that
|
|
40
|
+
* off where you run the program, or only around `make`, which then applies
|
|
41
|
+
* it to all of the client's calls:
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* import * as Effect from "effect/Effect";
|
|
45
|
+
* import * as HttpClient from "effect/http/HttpClient";
|
|
46
|
+
*
|
|
47
|
+
* declare const program: Effect.Effect<void>;
|
|
48
|
+
*
|
|
49
|
+
* const main = Effect.provideService(
|
|
50
|
+
* program,
|
|
51
|
+
* HttpClient.TracerPropagationEnabled,
|
|
52
|
+
* false,
|
|
53
|
+
* );
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* import * as Context from "effect/Context";
|
|
59
|
+
* import * as Effect from "effect/Effect";
|
|
60
|
+
* import * as Layer from "effect/Layer";
|
|
61
|
+
* import * as FetchHttpClient from "effect/http/FetchHttpClient";
|
|
62
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
63
|
+
*
|
|
64
|
+
* const make = Fetch.make({
|
|
65
|
+
* baseUrl: "https://api.example.com",
|
|
66
|
+
* headers: { accept: "application/json" },
|
|
67
|
+
* });
|
|
68
|
+
*
|
|
69
|
+
* class Api extends Context.Service<Api, Effect.Success<typeof make>>()("Api") {}
|
|
70
|
+
*
|
|
71
|
+
* const ApiLive = Layer.effect(Api)(make).pipe(
|
|
72
|
+
* Layer.provide(FetchHttpClient.layer),
|
|
73
|
+
* );
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @since 0.1.0
|
|
77
|
+
*/
|
|
78
|
+
const make = (config = {}) => Effect.gen(function* () {
|
|
79
|
+
const provided = yield* HttpClient.HttpClient;
|
|
80
|
+
const context = yield* Effect.context();
|
|
81
|
+
const plugins = config.plugins ?? [];
|
|
82
|
+
const duplicate = plugins.find((plugin, index) => plugins.findIndex((other) => other.name === plugin.name) !== index);
|
|
83
|
+
if (duplicate !== void 0) return yield* Effect.die(/* @__PURE__ */ new Error(`Two plugins are named "${duplicate.name}"`));
|
|
84
|
+
const baseUrl = config.baseUrl === void 0 || typeof config.baseUrl === "string" ? config.baseUrl : yield* config.baseUrl;
|
|
85
|
+
if (baseUrl !== void 0 && hasQueryOrFragment(baseUrl)) return yield* Effect.die(/* @__PURE__ */ new Error("The baseUrl can't contain a query string or a fragment: pass query parameters with the `query` option of each call"));
|
|
86
|
+
const applyDefaults = makeApplyDefaults(baseUrl, Headers.fromInput(config.headers));
|
|
87
|
+
const chain = applyPlugins(mapClientErrors(provided), plugins, context);
|
|
88
|
+
const httpClient = HttpClient.mapRequestInput(HttpClient.transformResponse(chain, provideOverrides(void 0)), applyDefaults);
|
|
89
|
+
const call = makeCall({
|
|
90
|
+
errorSchema: config.error,
|
|
91
|
+
applyDefaults,
|
|
92
|
+
chain,
|
|
93
|
+
plugins,
|
|
94
|
+
context
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
httpClient,
|
|
98
|
+
request: (method, path, options) => call(method)(path, options),
|
|
99
|
+
get: call("GET"),
|
|
100
|
+
head: call("HEAD"),
|
|
101
|
+
options: call("OPTIONS"),
|
|
102
|
+
post: call("POST"),
|
|
103
|
+
put: call("PUT"),
|
|
104
|
+
patch: call("PATCH"),
|
|
105
|
+
del: call("DELETE")
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
//#endregion
|
|
110
|
+
export { make };
|
package/dist/Errors.d.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import * as Headers from "effect/http/Headers";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
//#region src/Errors.d.ts
|
|
4
|
+
declare const NetworkError_base: Schema.Class<
|
|
5
|
+
NetworkError,
|
|
6
|
+
Schema.TaggedStruct<
|
|
7
|
+
"NetworkError",
|
|
8
|
+
{
|
|
9
|
+
readonly method: Schema.String;
|
|
10
|
+
readonly url: Schema.String;
|
|
11
|
+
readonly cause: Schema.Unknown;
|
|
12
|
+
}
|
|
13
|
+
>,
|
|
14
|
+
import("effect/Cause").YieldableError
|
|
15
|
+
>;
|
|
16
|
+
/**
|
|
17
|
+
* The request never produced a response: DNS failure, connection refused,
|
|
18
|
+
* CORS rejection, the device is offline, and so on.
|
|
19
|
+
*
|
|
20
|
+
* `cause` is the underlying error from the platform's HTTP client.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import * as Effect from "effect/Effect";
|
|
25
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
26
|
+
*
|
|
27
|
+
* declare const call: Effect.Effect<string, Fetch.FetchError>;
|
|
28
|
+
*
|
|
29
|
+
* const program = call.pipe(
|
|
30
|
+
* Effect.catchTag("NetworkError", (error) =>
|
|
31
|
+
* Effect.succeed(`Offline? ${error.method} ${error.url}`),
|
|
32
|
+
* ),
|
|
33
|
+
* );
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* @since 0.1.0
|
|
37
|
+
*/
|
|
38
|
+
export declare class NetworkError extends NetworkError_base {
|
|
39
|
+
/** `"<method> <url>: network error"`. */
|
|
40
|
+
get message(): string;
|
|
41
|
+
}
|
|
42
|
+
declare const HttpError_base: Schema.Class<
|
|
43
|
+
HttpError,
|
|
44
|
+
Schema.TaggedStruct<
|
|
45
|
+
"HttpError",
|
|
46
|
+
{
|
|
47
|
+
readonly method: Schema.String;
|
|
48
|
+
readonly url: Schema.String;
|
|
49
|
+
readonly status: Schema.Number;
|
|
50
|
+
readonly headers: Schema.declare<Headers.Headers, unknown>;
|
|
51
|
+
readonly text: Schema.String;
|
|
52
|
+
readonly body: Schema.Unknown;
|
|
53
|
+
}
|
|
54
|
+
>,
|
|
55
|
+
import("effect/Cause").YieldableError
|
|
56
|
+
>;
|
|
57
|
+
/**
|
|
58
|
+
* The server answered with a non-2xx status.
|
|
59
|
+
*
|
|
60
|
+
* Only 2xx counts as success, as in `fetch`'s `response.ok`. 3xx is an
|
|
61
|
+
* error too: `fetch` follows redirects itself, so a 3xx only reaches you
|
|
62
|
+
* with `redirect: "manual"` or a conditional request answered with
|
|
63
|
+
* `304 Not Modified`. Catch it by `status` if you expect one.
|
|
64
|
+
*
|
|
65
|
+
* - `url` is the request URL without its query string, so secrets in query
|
|
66
|
+
* parameters stay out of logs.
|
|
67
|
+
* - `headers` are the response headers. They're Effect `Headers`, so
|
|
68
|
+
* sensitive ones (`set-cookie`, `authorization`, …) print as
|
|
69
|
+
* `<redacted>` when the error is logged or serialised.
|
|
70
|
+
* - `text` is always the raw response body (`""` if it couldn't be read).
|
|
71
|
+
* - `body` is the body decoded with the call's (or client's) `error` schema,
|
|
72
|
+
* or `undefined` if it didn't match. Without an `error` schema, `body` is
|
|
73
|
+
* the parsed JSON when `text` is valid JSON, else `undefined`. An empty
|
|
74
|
+
* body is always `undefined`. Reading `body` never fails the call.
|
|
75
|
+
*
|
|
76
|
+
* Use {@link HttpError.With} for the type of an `HttpError` whose `body` was
|
|
77
|
+
* decoded with a known schema.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* import * as Effect from "effect/Effect";
|
|
82
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
83
|
+
*
|
|
84
|
+
* declare const call: Effect.Effect<
|
|
85
|
+
* string,
|
|
86
|
+
* Fetch.HttpError.With<{ readonly code: string }>
|
|
87
|
+
* >;
|
|
88
|
+
*
|
|
89
|
+
* const program = call.pipe(
|
|
90
|
+
* Effect.catchTag("HttpError", (error) =>
|
|
91
|
+
* error.status === 404
|
|
92
|
+
* ? Effect.succeed("not found")
|
|
93
|
+
* : Effect.fail(error.body?.code ?? `HTTP ${error.status}`),
|
|
94
|
+
* ),
|
|
95
|
+
* );
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* @since 0.1.0
|
|
99
|
+
*/
|
|
100
|
+
export declare class HttpError extends HttpError_base {
|
|
101
|
+
/** `"<method> <url>: HTTP <status>"`. */
|
|
102
|
+
get message(): string;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Types related to {@link HttpError}.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```ts
|
|
109
|
+
* import type * as Fetch from "@monarcode/effetch";
|
|
110
|
+
*
|
|
111
|
+
* type NotFound = Fetch.HttpError.With<{ readonly resource: string }>;
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
114
|
+
* @since 0.1.0
|
|
115
|
+
*/
|
|
116
|
+
export declare namespace HttpError {
|
|
117
|
+
/**
|
|
118
|
+
* An {@link HttpError} whose `body` was decoded as `B`. `body` is
|
|
119
|
+
* `undefined` when the response didn't match the error schema.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* import type * as Fetch from "@monarcode/effetch";
|
|
124
|
+
*
|
|
125
|
+
* type ApiHttpError = Fetch.HttpError.With<{ readonly code: string }>;
|
|
126
|
+
*
|
|
127
|
+
* const code = (error: ApiHttpError) => error.body?.code;
|
|
128
|
+
* ```
|
|
129
|
+
*
|
|
130
|
+
* @since 0.1.0
|
|
131
|
+
*/
|
|
132
|
+
type With<B> = HttpError & {
|
|
133
|
+
readonly body: B | undefined;
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
declare const DecodeError_base: Schema.Class<
|
|
137
|
+
DecodeError,
|
|
138
|
+
Schema.TaggedStruct<
|
|
139
|
+
"DecodeError",
|
|
140
|
+
{
|
|
141
|
+
readonly method: Schema.String;
|
|
142
|
+
readonly url: Schema.String;
|
|
143
|
+
readonly status: Schema.Number;
|
|
144
|
+
readonly issue: Schema.Unknown;
|
|
145
|
+
readonly message: Schema.String;
|
|
146
|
+
}
|
|
147
|
+
>,
|
|
148
|
+
import("effect/Cause").YieldableError
|
|
149
|
+
>;
|
|
150
|
+
/**
|
|
151
|
+
* A 2xx response body didn't match the call's `output` schema, wasn't valid
|
|
152
|
+
* JSON, or couldn't be read.
|
|
153
|
+
*
|
|
154
|
+
* `issue` is what went wrong and `message` is a readable summary of it:
|
|
155
|
+
*
|
|
156
|
+
* - the schema issue when the body doesn't match `output`,
|
|
157
|
+
* - the `SyntaxError` when the body isn't valid JSON,
|
|
158
|
+
* - the `HttpClientError` when the body couldn't be read.
|
|
159
|
+
*
|
|
160
|
+
* An empty body (204, HEAD, …) is decoded as JSON `null`, so it only
|
|
161
|
+
* passes schemas that accept nothing, such as `Schema.Void`.
|
|
162
|
+
*
|
|
163
|
+
* `url` is the request URL without its query string.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* import * as Effect from "effect/Effect";
|
|
168
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
169
|
+
*
|
|
170
|
+
* declare const call: Effect.Effect<string, Fetch.FetchError>;
|
|
171
|
+
*
|
|
172
|
+
* const program = call.pipe(
|
|
173
|
+
* Effect.catchTag("DecodeError", (error) =>
|
|
174
|
+
* Effect.succeed(`Unexpected response from ${error.url}: ${error.message}`),
|
|
175
|
+
* ),
|
|
176
|
+
* );
|
|
177
|
+
* ```
|
|
178
|
+
*
|
|
179
|
+
* @since 0.1.0
|
|
180
|
+
*/
|
|
181
|
+
export declare class DecodeError extends DecodeError_base {}
|
|
182
|
+
/**
|
|
183
|
+
* Every error the library itself can fail a call with. Plugins may add
|
|
184
|
+
* their own errors on top of these.
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* ```ts
|
|
188
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
189
|
+
*
|
|
190
|
+
* const describe = (error: Fetch.FetchError): string => {
|
|
191
|
+
* switch (error._tag) {
|
|
192
|
+
* case "NetworkError":
|
|
193
|
+
* return "Network unavailable";
|
|
194
|
+
* case "HttpError":
|
|
195
|
+
* return `Server error ${error.status}`;
|
|
196
|
+
* case "DecodeError":
|
|
197
|
+
* return "Unexpected response";
|
|
198
|
+
* }
|
|
199
|
+
* };
|
|
200
|
+
* ```
|
|
201
|
+
*
|
|
202
|
+
* @since 0.1.0
|
|
203
|
+
*/
|
|
204
|
+
export type FetchError = NetworkError | HttpError | DecodeError;
|
|
205
|
+
/**
|
|
206
|
+
* Returns `true` if `u` is a {@link NetworkError}, {@link HttpError} or
|
|
207
|
+
* {@link DecodeError}.
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* ```ts
|
|
211
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
212
|
+
*
|
|
213
|
+
* const report = (error: unknown) => {
|
|
214
|
+
* if (Fetch.isFetchError(error)) {
|
|
215
|
+
* console.error(error._tag, error.url);
|
|
216
|
+
* }
|
|
217
|
+
* };
|
|
218
|
+
* ```
|
|
219
|
+
*
|
|
220
|
+
* @since 0.1.0
|
|
221
|
+
*/
|
|
222
|
+
export declare const isFetchError: (u: unknown) => u is FetchError;
|
|
223
|
+
//#endregion
|
|
224
|
+
export {};
|