@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
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { hasOwn } from "./object.js";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/path.ts
|
|
4
|
+
/**
|
|
5
|
+
* Replaces each `:name` segment of `path` with the URI-encoded value of
|
|
6
|
+
* `params[name]`. Splits on `/` exactly like `PathParamNames`, so the
|
|
7
|
+
* runtime and the types agree.
|
|
8
|
+
*
|
|
9
|
+
* Only own properties of `params` count, so `:constructor` or `:toString`
|
|
10
|
+
* never pick up inherited members.
|
|
11
|
+
*
|
|
12
|
+
* Throws if a parameter is missing. Callers run it inside an Effect, where
|
|
13
|
+
* the throw becomes a defect.
|
|
14
|
+
*/
|
|
15
|
+
const interpolatePath = (path, params) => path.split("/").map((segment) => {
|
|
16
|
+
if (segment.length < 2 || !segment.startsWith(":")) return segment;
|
|
17
|
+
const name = segment.slice(1);
|
|
18
|
+
if (params === void 0 || !hasOwn(params, name)) throw new Error(`Missing path parameter "${name}" for path "${path}"`);
|
|
19
|
+
return encodeURIComponent(String(params[name]));
|
|
20
|
+
}).join("/");
|
|
21
|
+
|
|
22
|
+
//#endregion
|
|
23
|
+
export { interpolatePath };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NetworkError } from "../Errors.js";
|
|
2
|
+
import { errorUrl, joinUrl } from "./url.js";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Headers from "effect/http/Headers";
|
|
5
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
6
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
7
|
+
|
|
8
|
+
//#region src/internal/pipeline.ts
|
|
9
|
+
const mapClientError = (error) => {
|
|
10
|
+
const { reason } = error;
|
|
11
|
+
switch (reason._tag) {
|
|
12
|
+
case "TransportError": return Effect.fail(new NetworkError({
|
|
13
|
+
method: reason.request.method,
|
|
14
|
+
url: errorUrl(reason.request.url),
|
|
15
|
+
cause: reason.cause
|
|
16
|
+
}));
|
|
17
|
+
case "StatusCodeError": return Effect.succeed(reason.response);
|
|
18
|
+
default: return Effect.die(error);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const mapClientErrors = (client) => HttpClient.transformResponse(client, Effect.catch(mapClientError));
|
|
22
|
+
const bodyHeaders = ["content-type", "content-length"];
|
|
23
|
+
const makeApplyDefaults = (baseUrl, headers) => {
|
|
24
|
+
const defaults = Headers.removeMany(headers, bodyHeaders);
|
|
25
|
+
return (request) => HttpClientRequest.setHeaders(HttpClientRequest.setUrl(request, joinUrl(baseUrl, request.url)), Headers.merge(defaults, request.headers));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
export { makeApplyDefaults, mapClientErrors };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { hasOwn } from "./object.js";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
4
|
+
import * as Cause from "effect/Cause";
|
|
5
|
+
import * as Context from "effect/Context";
|
|
6
|
+
|
|
7
|
+
//#region src/internal/plugins.ts
|
|
8
|
+
const noOverrides = {};
|
|
9
|
+
/**
|
|
10
|
+
* The `plugins` option of the call being run. Provided around each call;
|
|
11
|
+
* anything else (such as `client.httpClient`) sees no overrides.
|
|
12
|
+
*/
|
|
13
|
+
const CurrentOverrides = Context.Reference("@monarcode/effetch/CurrentOverrides", { defaultValue: () => noOverrides });
|
|
14
|
+
const provideOverrides = (overrides) => Effect.provideService(CurrentOverrides, overrides ?? noOverrides);
|
|
15
|
+
const overrideOf = (overrides, name) => hasOwn(overrides, name) ? overrides[name] : void 0;
|
|
16
|
+
const isRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u);
|
|
17
|
+
/**
|
|
18
|
+
* `{ ...defaults, ...override }`, leaving out the override's `undefined`
|
|
19
|
+
* values so they keep their default. Only object options can be
|
|
20
|
+
* overridden; anything else keeps `defaults`.
|
|
21
|
+
*/
|
|
22
|
+
const mergeOptions = (defaults, override) => isRecord(defaults) && isRecord(override) ? {
|
|
23
|
+
...defaults,
|
|
24
|
+
...Object.fromEntries(Object.entries(override).filter(([, value]) => value !== void 0))
|
|
25
|
+
} : defaults;
|
|
26
|
+
const fromExecute = (execute) => HttpClient.makeWith(Effect.flatMap(execute), Effect.succeed);
|
|
27
|
+
const executeOf = (client) => (request) => client.execute(request);
|
|
28
|
+
/**
|
|
29
|
+
* One plugin around `inner`: its `wrap` encloses its `onRequest` and
|
|
30
|
+
* `onResponse`, and a call that sets the plugin to `false` goes straight to
|
|
31
|
+
* `inner`.
|
|
32
|
+
*
|
|
33
|
+
* Each layer is a plain `execute` function rather than an `HttpClient`
|
|
34
|
+
* split into Effect's `preprocess` and `postprocess`, so a `wrap` that
|
|
35
|
+
* re-runs its client (a retry) re-runs only what is inside it.
|
|
36
|
+
*/
|
|
37
|
+
const layer = (plugin, inner) => {
|
|
38
|
+
const { onRequest, onResponse, wrap } = plugin;
|
|
39
|
+
if (onRequest === void 0 && onResponse === void 0 && wrap === void 0) return inner;
|
|
40
|
+
const options = Effect.map(Effect.service(CurrentOverrides), (overrides) => mergeOptions(plugin.defaults, overrideOf(overrides, plugin.name)));
|
|
41
|
+
const hooked = onRequest === void 0 && onResponse === void 0 ? inner : (request) => Effect.gen(function* () {
|
|
42
|
+
const current = yield* options;
|
|
43
|
+
const sent = onRequest === void 0 ? request : yield* onRequest(request, {
|
|
44
|
+
request,
|
|
45
|
+
options: current
|
|
46
|
+
});
|
|
47
|
+
const response = yield* inner(sent);
|
|
48
|
+
return onResponse === void 0 ? response : yield* onResponse(response, {
|
|
49
|
+
request: sent,
|
|
50
|
+
options: current
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
const wrapped = wrap === void 0 ? hooked : executeOf(wrap(fromExecute(hooked), options));
|
|
54
|
+
return (request) => Effect.flatMap(Effect.service(CurrentOverrides), (overrides) => overrideOf(overrides, plugin.name) === false ? inner(request) : wrapped(request));
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Runs `effect` with the services captured by `make` filling in whatever the
|
|
58
|
+
* calling fiber doesn't have. The caller's own services and references win,
|
|
59
|
+
* as with Effect's `HttpClient.layerMergedContext`.
|
|
60
|
+
*/
|
|
61
|
+
const withContext = (context) => (effect) => Effect.updateContext(effect, (input) => Context.merge(context, input));
|
|
62
|
+
/**
|
|
63
|
+
* The plugins around `client`, `plugins[0]` outermost, run with the captured
|
|
64
|
+
* `context`.
|
|
65
|
+
*/
|
|
66
|
+
const applyPlugins = (client, plugins, context) => {
|
|
67
|
+
const execute = plugins.reduceRight((inner, plugin) => layer(plugin, inner), executeOf(client));
|
|
68
|
+
return fromExecute((request) => withContext(context)(execute(request)));
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Runs the `onError` hooks, in array order, for a call that failed or died
|
|
72
|
+
* after `request` was built. Interruption isn't a failure, so it runs none.
|
|
73
|
+
* Plugins skipped by the call are left out. A hook that dies is logged at
|
|
74
|
+
* Debug level and never changes the call's outcome.
|
|
75
|
+
*/
|
|
76
|
+
const runErrorHooks = (plugins, context, request, cause) => {
|
|
77
|
+
if (!Cause.hasFails(cause) && !Cause.hasDies(cause)) return Effect.void;
|
|
78
|
+
const error = Cause.squash(cause);
|
|
79
|
+
const hooks = Effect.flatMap(Effect.service(CurrentOverrides), (overrides) => Effect.forEach(plugins, (plugin) => {
|
|
80
|
+
const override = overrideOf(overrides, plugin.name);
|
|
81
|
+
if (plugin.onError === void 0 || override === false) return Effect.void;
|
|
82
|
+
const options = mergeOptions(plugin.defaults, override);
|
|
83
|
+
return Effect.catchCause(plugin.onError(error, {
|
|
84
|
+
request,
|
|
85
|
+
options
|
|
86
|
+
}), (failure) => Effect.logDebug(`effetch: the onError hook of plugin "${plugin.name}" failed`, failure));
|
|
87
|
+
}, { discard: true }));
|
|
88
|
+
return withContext(context)(hooks);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
//#endregion
|
|
92
|
+
export { CurrentOverrides, applyPlugins, mergeOptions, provideOverrides, runErrorHooks, withContext };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as UrlParams from "effect/http/UrlParams";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/query.ts
|
|
4
|
+
/**
|
|
5
|
+
* Converts the `query` option into `UrlParams`, in key order. Arrays repeat
|
|
6
|
+
* the key; `null` and `undefined` values and array items are dropped.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately not `UrlParams.fromInput(query)`: that sends a `null` value
|
|
9
|
+
* as the string `"null"` and keeps `null` array items.
|
|
10
|
+
*/
|
|
11
|
+
const toUrlParams = (query) => {
|
|
12
|
+
const entries = [];
|
|
13
|
+
if (query !== void 0) {
|
|
14
|
+
for (const [key, value] of Object.entries(query)) if (Array.isArray(value)) {
|
|
15
|
+
for (const item of value) if (item !== null && item !== void 0) entries.push([key, String(item)]);
|
|
16
|
+
} else if (value !== null && value !== void 0) entries.push([key, String(value)]);
|
|
17
|
+
}
|
|
18
|
+
return UrlParams.make(entries);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
export { toUrlParams };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { provideOverrides, runErrorHooks } from "./plugins.js";
|
|
2
|
+
import { encodeBody } from "./body.js";
|
|
3
|
+
import { interpolatePath } from "./path.js";
|
|
4
|
+
import { toUrlParams } from "./query.js";
|
|
5
|
+
import { checkStatus, decodeOutput } from "./response.js";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Headers from "effect/http/Headers";
|
|
8
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
9
|
+
|
|
10
|
+
//#region src/internal/request.ts
|
|
11
|
+
const buildRequest = (state, method, path, options) => Effect.gen(function* () {
|
|
12
|
+
if (options.body !== void 0 && (method === "GET" || method === "HEAD")) return yield* Effect.die(/* @__PURE__ */ new Error(`A ${method} request can't have a body`));
|
|
13
|
+
let request = HttpClientRequest.make(method)(interpolatePath(path, options.params), { urlParams: toUrlParams(options.query) });
|
|
14
|
+
const body = yield* encodeBody(options.body, options.input);
|
|
15
|
+
if (body !== void 0) request = HttpClientRequest.setBody(request, body);
|
|
16
|
+
if (options.headers !== void 0) request = HttpClientRequest.setHeaders(request, options.headers);
|
|
17
|
+
request = state.applyDefaults(request);
|
|
18
|
+
if (options.output !== void 0 && !Headers.has(request.headers, "accept")) request = HttpClientRequest.acceptJson(request);
|
|
19
|
+
return request;
|
|
20
|
+
});
|
|
21
|
+
const execute = (state, request, options) => Effect.gen(function* () {
|
|
22
|
+
const response = yield* state.chain.execute(request);
|
|
23
|
+
const checked = yield* checkStatus(response, options.error ?? state.errorSchema);
|
|
24
|
+
return options.output === void 0 ? checked : yield* decodeOutput(checked, options.output);
|
|
25
|
+
});
|
|
26
|
+
const send = (state, method, path, options) => Effect.gen(function* () {
|
|
27
|
+
const request = yield* buildRequest(state, method, path, options);
|
|
28
|
+
return yield* Effect.tapCause(execute(state, request, options), (cause) => runErrorHooks(state.plugins, state.context, request, cause));
|
|
29
|
+
}).pipe(provideOverrides(options.plugins));
|
|
30
|
+
const makeCall = (state) => (method) => (path, options = {}) => send(state, method, path, options);
|
|
31
|
+
|
|
32
|
+
//#endregion
|
|
33
|
+
export { makeCall };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/resolve.ts
|
|
4
|
+
const isThenable = (u) => typeof u === "object" && u !== null && typeof u.then === "function";
|
|
5
|
+
/**
|
|
6
|
+
* Resolves a bearer token source for `request`, in the calling fiber. The
|
|
7
|
+
* forms are told apart in this order: a string, an Effect (which may also
|
|
8
|
+
* be a function, like a `Context.Service` class), then a function.
|
|
9
|
+
*
|
|
10
|
+
* A function that throws or returns a rejected promise is a defect.
|
|
11
|
+
* Interrupting the Effect stops waiting on a pending promise.
|
|
12
|
+
*/
|
|
13
|
+
const resolveTokenSource = (source, request) => {
|
|
14
|
+
if (typeof source === "string") return Effect.succeed(source);
|
|
15
|
+
if (Effect.isEffect(source)) return source;
|
|
16
|
+
return Effect.suspend(() => {
|
|
17
|
+
const value = source(request);
|
|
18
|
+
return isThenable(value) ? Effect.promise(() => value) : Effect.succeed(value);
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
//#endregion
|
|
23
|
+
export { resolveTokenSource };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { DecodeError, HttpError } from "../Errors.js";
|
|
2
|
+
import { errorUrl } from "./url.js";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
|
|
6
|
+
//#region src/internal/response.ts
|
|
7
|
+
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
8
|
+
/**
|
|
9
|
+
* `undefined` for an empty or whitespace-only body, else the parsed JSON.
|
|
10
|
+
* Throws on invalid JSON. `JSON.parse` never returns `undefined`, so an
|
|
11
|
+
* empty body and a JSON `null` stay distinguishable here.
|
|
12
|
+
*/
|
|
13
|
+
const parseBody = (text) => text.trim() === "" ? void 0 : JSON.parse(text);
|
|
14
|
+
const decodeJson = (schema) => Schema.decodeUnknownEffect(Schema.toCodecJson(schema));
|
|
15
|
+
const readErrorBody = (text, schema) => {
|
|
16
|
+
let json;
|
|
17
|
+
try {
|
|
18
|
+
json = parseBody(text);
|
|
19
|
+
} catch {
|
|
20
|
+
return Effect.succeed(void 0);
|
|
21
|
+
}
|
|
22
|
+
if (json === void 0 || schema === void 0) return Effect.succeed(json);
|
|
23
|
+
return Effect.orElseSucceed(decodeJson(schema)(json), () => void 0);
|
|
24
|
+
};
|
|
25
|
+
function checkStatus(response, errorSchema) {
|
|
26
|
+
if (response.status >= 200 && response.status < 300) return Effect.succeed(response);
|
|
27
|
+
return Effect.gen(function* () {
|
|
28
|
+
const text = yield* Effect.orElseSucceed(response.text, () => "");
|
|
29
|
+
const body = yield* readErrorBody(text, errorSchema);
|
|
30
|
+
return yield* Effect.fail(new HttpError({
|
|
31
|
+
method: response.request.method,
|
|
32
|
+
url: errorUrl(response.request.url),
|
|
33
|
+
status: response.status,
|
|
34
|
+
headers: response.headers,
|
|
35
|
+
text,
|
|
36
|
+
body
|
|
37
|
+
}));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Decodes a 2xx response body with `output`.
|
|
42
|
+
*
|
|
43
|
+
* An empty or whitespace-only body (204, 205, HEAD, …) is decoded as JSON
|
|
44
|
+
* `null`, which `Schema.Void`, `Schema.Undefined` and `Schema.NullOr`
|
|
45
|
+
* accept. A JSON `null` body is therefore indistinguishable from an empty
|
|
46
|
+
* one.
|
|
47
|
+
*
|
|
48
|
+
* Fails with a `DecodeError` whose `issue` is:
|
|
49
|
+
*
|
|
50
|
+
* - the `HttpClientError` when the body can't be read,
|
|
51
|
+
* - the `SyntaxError` when it isn't valid JSON,
|
|
52
|
+
* - the schema issue when it doesn't match `output`.
|
|
53
|
+
*/
|
|
54
|
+
const decodeOutput = (response, output) => {
|
|
55
|
+
const failure = (issue, message) => new DecodeError({
|
|
56
|
+
method: response.request.method,
|
|
57
|
+
url: errorUrl(response.request.url),
|
|
58
|
+
status: response.status,
|
|
59
|
+
issue,
|
|
60
|
+
message
|
|
61
|
+
});
|
|
62
|
+
return Effect.gen(function* () {
|
|
63
|
+
const text = yield* Effect.mapError(response.text, (error) => failure(error, `Failed to read the response body: ${messageOf(error.cause ?? error)}`));
|
|
64
|
+
const json = yield* Effect.try({
|
|
65
|
+
try: () => parseBody(text),
|
|
66
|
+
catch: (cause) => failure(cause, `The response body isn't valid JSON: ${messageOf(cause)}`)
|
|
67
|
+
});
|
|
68
|
+
return yield* Effect.mapError(decodeJson(output)(json ?? null), (error) => failure(error.issue, json === void 0 ? `The response body is empty, but the output schema requires a value: ${error.message}` : error.message));
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
export { checkStatus, decodeOutput };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/internal/url.ts
|
|
2
|
+
const absoluteUrl = /^https?:\/\//i;
|
|
3
|
+
/**
|
|
4
|
+
* True for `http://` and `https://` URLs only. Protocol-relative (`//host`)
|
|
5
|
+
* and other schemes are treated as paths, so a request can't switch host or
|
|
6
|
+
* protocol by accident.
|
|
7
|
+
*/
|
|
8
|
+
const isAbsoluteUrl = (path) => absoluteUrl.test(path);
|
|
9
|
+
/**
|
|
10
|
+
* Joins `base` and `path` with exactly one `/`. An absolute `path` (see
|
|
11
|
+
* `isAbsoluteUrl`) is returned unchanged, as is any `path` when there's no
|
|
12
|
+
* `base`. Uses string operations, not `new URL()`, for React Native.
|
|
13
|
+
*/
|
|
14
|
+
const joinUrl = (base, path) => base === void 0 || isAbsoluteUrl(path) ? path : base.replace(/\/+$/, "") + "/" + path.replace(/^\/+/, "");
|
|
15
|
+
/**
|
|
16
|
+
* `url` without its query string or fragment, so secrets such as
|
|
17
|
+
* `?api_key=…` never reach error messages or logs.
|
|
18
|
+
*/
|
|
19
|
+
const errorUrl = (url) => url.split(/[?#]/, 1)[0] ?? "";
|
|
20
|
+
/** True if `url` contains a query string or a fragment. */
|
|
21
|
+
const hasQueryOrFragment = (url) => /[?#]/.test(url);
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
export { errorUrl, hasQueryOrFragment, isAbsoluteUrl, joinUrl };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
3
|
+
|
|
4
|
+
import { Plugin } from "../Plugin.js";
|
|
5
|
+
//#region src/plugins/bearer.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* `T` itself, once known. In {@link BearerPlugin} it stops TypeScript
|
|
8
|
+
* inferring the token's error and services from where the plugin goes: in
|
|
9
|
+
* a `plugins` list they would become `any`.
|
|
10
|
+
*/
|
|
11
|
+
type Settled<T> = [T] extends [infer U] ? U : never;
|
|
12
|
+
/**
|
|
13
|
+
* What a token source gives: the token, or `null`, `undefined` or `""` to
|
|
14
|
+
* send the request without an `Authorization` header.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import type { TokenValue } from "@monarcode/effetch/plugins";
|
|
19
|
+
*
|
|
20
|
+
* const signedOut: TokenValue = undefined;
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* @since 0.1.0
|
|
24
|
+
*/
|
|
25
|
+
export type TokenValue = string | null | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Where {@link bearer} gets the token, for every request:
|
|
28
|
+
*
|
|
29
|
+
* - a string, for a token that never changes;
|
|
30
|
+
* - a function of the request, returning the token or a promise of it;
|
|
31
|
+
* - an Effect, whose error and services become the plugin's (and so the
|
|
32
|
+
* client's).
|
|
33
|
+
*
|
|
34
|
+
* A function that throws, or whose promise rejects, makes the call die
|
|
35
|
+
* (a defect), so the error type stays clean. To fail with a typed error,
|
|
36
|
+
* pass an Effect.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* import * as Effect from "effect/Effect";
|
|
41
|
+
* import type { TokenSource } from "@monarcode/effetch/plugins";
|
|
42
|
+
*
|
|
43
|
+
* declare const store: { getState(): { accessToken?: string } };
|
|
44
|
+
* declare const readToken: () => Promise<string | null>;
|
|
45
|
+
*
|
|
46
|
+
* const fixed: TokenSource = "dev-token";
|
|
47
|
+
* const sync: TokenSource = () => store.getState().accessToken;
|
|
48
|
+
* const async: TokenSource = () => readToken();
|
|
49
|
+
* const fromEffect: TokenSource = Effect.succeed("token");
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* @since 0.1.0
|
|
53
|
+
*/
|
|
54
|
+
export type TokenSource<E = never, R = never> =
|
|
55
|
+
| string
|
|
56
|
+
| ((
|
|
57
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
58
|
+
) => TokenValue | PromiseLike<TokenValue>)
|
|
59
|
+
| Effect.Effect<TokenValue, E, R>;
|
|
60
|
+
/**
|
|
61
|
+
* Options for {@link bearer}.
|
|
62
|
+
*
|
|
63
|
+
* - `token`: where the token comes from (see {@link TokenSource}). It's
|
|
64
|
+
* resolved for every request, inside that request's fiber, and never
|
|
65
|
+
* cached, so a refreshed or cleared token takes effect on the next
|
|
66
|
+
* request.
|
|
67
|
+
* - `scheme`: the word before the token. Defaults to `"Bearer"`.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* import type { BearerOptions } from "@monarcode/effetch/plugins";
|
|
72
|
+
*
|
|
73
|
+
* const options: BearerOptions = { token: "dev-token", scheme: "Token" };
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @since 0.1.0
|
|
77
|
+
*/
|
|
78
|
+
export interface BearerOptions<E = never, R = never> {
|
|
79
|
+
/** Where the token comes from, resolved for every request. */
|
|
80
|
+
readonly token: TokenSource<E, R>;
|
|
81
|
+
/** The word before the token. Defaults to `"Bearer"`. */
|
|
82
|
+
readonly scheme?: string | undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The plugin made by {@link bearer}: it fails with the token Effect's `E`
|
|
86
|
+
* and needs its `R`. Calls can only skip it (`plugins: { bearer: false }`).
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* import { bearer, type BearerPlugin } from "@monarcode/effetch/plugins";
|
|
91
|
+
*
|
|
92
|
+
* const auth: BearerPlugin = bearer({ token: "dev-token" });
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* @since 0.1.0
|
|
96
|
+
*/
|
|
97
|
+
export type BearerPlugin<E = never, R = never> = Plugin<
|
|
98
|
+
"bearer",
|
|
99
|
+
Settled<E>,
|
|
100
|
+
Settled<R>
|
|
101
|
+
>;
|
|
102
|
+
/**
|
|
103
|
+
* Sends `Authorization: <scheme> <token>` with every request.
|
|
104
|
+
*
|
|
105
|
+
* The header is left out when the token is `null`, `undefined` or `""`,
|
|
106
|
+
* and a request that already has an `Authorization` header keeps it (the
|
|
107
|
+
* token isn't resolved then).
|
|
108
|
+
*
|
|
109
|
+
* Put it last in the plugin list, so retries ask for the token again.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* import * as Context from "effect/Context";
|
|
114
|
+
* import * as Effect from "effect/Effect";
|
|
115
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
116
|
+
* import { bearer } from "@monarcode/effetch/plugins";
|
|
117
|
+
*
|
|
118
|
+
* declare const SecureStore: {
|
|
119
|
+
* getItemAsync(key: string): Promise<string | null>;
|
|
120
|
+
* };
|
|
121
|
+
*
|
|
122
|
+
* class Session extends Context.Service<
|
|
123
|
+
* Session,
|
|
124
|
+
* { readonly accessToken: Effect.Effect<string | undefined> }
|
|
125
|
+
* >()("Session") {}
|
|
126
|
+
*
|
|
127
|
+
* const fromStorage = bearer({
|
|
128
|
+
* token: () => SecureStore.getItemAsync("access_token"),
|
|
129
|
+
* });
|
|
130
|
+
*
|
|
131
|
+
* const fromSession = bearer({
|
|
132
|
+
* token: Effect.gen(function* () {
|
|
133
|
+
* const session = yield* Session;
|
|
134
|
+
* return yield* session.accessToken;
|
|
135
|
+
* }),
|
|
136
|
+
* });
|
|
137
|
+
*
|
|
138
|
+
* const make = Fetch.make({
|
|
139
|
+
* baseUrl: "https://api.example.com",
|
|
140
|
+
* plugins: [fromSession],
|
|
141
|
+
* });
|
|
142
|
+
*
|
|
143
|
+
* // Per call: api.post("/auth/login", { body, plugins: { bearer: false } })
|
|
144
|
+
* ```
|
|
145
|
+
*
|
|
146
|
+
* @since 0.1.0
|
|
147
|
+
*/
|
|
148
|
+
export declare const bearer: <E = never, R = never>(
|
|
149
|
+
options: BearerOptions<E, R>,
|
|
150
|
+
) => BearerPlugin<E, R>;
|
|
151
|
+
//#endregion
|
|
152
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { plugin } from "../Plugin.js";
|
|
2
|
+
import { resolveTokenSource } from "../internal/resolve.js";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Headers from "effect/http/Headers";
|
|
5
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
6
|
+
|
|
7
|
+
//#region src/plugins/bearer.ts
|
|
8
|
+
/**
|
|
9
|
+
* Sends `Authorization: <scheme> <token>` with every request.
|
|
10
|
+
*
|
|
11
|
+
* The header is left out when the token is `null`, `undefined` or `""`,
|
|
12
|
+
* and a request that already has an `Authorization` header keeps it (the
|
|
13
|
+
* token isn't resolved then).
|
|
14
|
+
*
|
|
15
|
+
* Put it last in the plugin list, so retries ask for the token again.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* import * as Context from "effect/Context";
|
|
20
|
+
* import * as Effect from "effect/Effect";
|
|
21
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
22
|
+
* import { bearer } from "@monarcode/effetch/plugins";
|
|
23
|
+
*
|
|
24
|
+
* declare const SecureStore: {
|
|
25
|
+
* getItemAsync(key: string): Promise<string | null>;
|
|
26
|
+
* };
|
|
27
|
+
*
|
|
28
|
+
* class Session extends Context.Service<
|
|
29
|
+
* Session,
|
|
30
|
+
* { readonly accessToken: Effect.Effect<string | undefined> }
|
|
31
|
+
* >()("Session") {}
|
|
32
|
+
*
|
|
33
|
+
* const fromStorage = bearer({
|
|
34
|
+
* token: () => SecureStore.getItemAsync("access_token"),
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* const fromSession = bearer({
|
|
38
|
+
* token: Effect.gen(function* () {
|
|
39
|
+
* const session = yield* Session;
|
|
40
|
+
* return yield* session.accessToken;
|
|
41
|
+
* }),
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* const make = Fetch.make({
|
|
45
|
+
* baseUrl: "https://api.example.com",
|
|
46
|
+
* plugins: [fromSession],
|
|
47
|
+
* });
|
|
48
|
+
*
|
|
49
|
+
* // Per call: api.post("/auth/login", { body, plugins: { bearer: false } })
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* @since 0.1.0
|
|
53
|
+
*/
|
|
54
|
+
const bearer = (options) => {
|
|
55
|
+
const scheme = options.scheme ?? "Bearer";
|
|
56
|
+
return plugin({
|
|
57
|
+
name: "bearer",
|
|
58
|
+
onRequest: (request) => Headers.has(request.headers, "authorization") ? Effect.succeed(request) : Effect.map(resolveTokenSource(options.token, request), (token) => token === void 0 || token === null || token === "" ? request : HttpClientRequest.setHeader(request, "authorization", `${scheme} ${token}`))
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
63
|
+
export { bearer };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BearerOptions,
|
|
3
|
+
BearerPlugin,
|
|
4
|
+
TokenSource,
|
|
5
|
+
TokenValue,
|
|
6
|
+
bearer,
|
|
7
|
+
} from "./bearer.js";
|
|
8
|
+
import { LoggerOptions, LoggerPlugin, logger } from "./logger.js";
|
|
9
|
+
import { RetryOptions, RetryPlugin, retry } from "./retry.js";
|
|
10
|
+
import {
|
|
11
|
+
TimeoutError,
|
|
12
|
+
TimeoutOptions,
|
|
13
|
+
TimeoutPlugin,
|
|
14
|
+
timeout,
|
|
15
|
+
} from "./timeout.js";
|
|
16
|
+
export {
|
|
17
|
+
type BearerOptions,
|
|
18
|
+
type BearerPlugin,
|
|
19
|
+
type LoggerOptions,
|
|
20
|
+
type LoggerPlugin,
|
|
21
|
+
type RetryOptions,
|
|
22
|
+
type RetryPlugin,
|
|
23
|
+
TimeoutError,
|
|
24
|
+
type TimeoutOptions,
|
|
25
|
+
type TimeoutPlugin,
|
|
26
|
+
type TokenSource,
|
|
27
|
+
type TokenValue,
|
|
28
|
+
bearer,
|
|
29
|
+
logger,
|
|
30
|
+
retry,
|
|
31
|
+
timeout,
|
|
32
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Plugin } from "../Plugin.js";
|
|
2
|
+
//#region src/plugins/logger.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Options for {@link logger}.
|
|
5
|
+
*
|
|
6
|
+
* - `level`: the log level of every entry, `"Debug"` (the default) or
|
|
7
|
+
* `"Info"`. Effect's default logger hides Debug entries unless the
|
|
8
|
+
* minimum log level allows them.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import type { LoggerOptions } from "@monarcode/effetch/plugins";
|
|
13
|
+
*
|
|
14
|
+
* const options: LoggerOptions = { level: "Info" };
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* @since 0.1.0
|
|
18
|
+
*/
|
|
19
|
+
export interface LoggerOptions {
|
|
20
|
+
/** The level of every entry. Defaults to `"Debug"`. */
|
|
21
|
+
readonly level?: "Debug" | "Info" | undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The plugin made by {@link logger}. Calls can only skip it
|
|
25
|
+
* (`plugins: { logger: false }`).
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { logger, type LoggerPlugin } from "@monarcode/effetch/plugins";
|
|
30
|
+
*
|
|
31
|
+
* const log: LoggerPlugin = logger();
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @since 0.1.0
|
|
35
|
+
*/
|
|
36
|
+
export type LoggerPlugin = Plugin<"logger">;
|
|
37
|
+
/**
|
|
38
|
+
* Logs one entry per call with Effect's logger: the method, the URL, the
|
|
39
|
+
* status and how long the response took, e.g.
|
|
40
|
+
* `GET https://api.example.com/todos 200 (35ms)`, or `… failed (35ms)`
|
|
41
|
+
* with the failure's cause.
|
|
42
|
+
*
|
|
43
|
+
* The same values are log annotations (`method`, `url`, `status`,
|
|
44
|
+
* `durationMs`). The URL has no query string, and no headers are logged,
|
|
45
|
+
* so tokens and cookies stay out of logs. The duration is measured with
|
|
46
|
+
* Effect's `Clock` until the response headers arrive, in whole
|
|
47
|
+
* milliseconds.
|
|
48
|
+
*
|
|
49
|
+
* Put it first in the plugin list, so it sees the whole call, retries
|
|
50
|
+
* included.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* import * as Fetch from "@monarcode/effetch";
|
|
55
|
+
* import { logger } from "@monarcode/effetch/plugins";
|
|
56
|
+
*
|
|
57
|
+
* const make = Fetch.make({
|
|
58
|
+
* baseUrl: "https://api.example.com",
|
|
59
|
+
* plugins: [logger({ level: "Info" })],
|
|
60
|
+
* });
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @since 0.1.0
|
|
64
|
+
*/
|
|
65
|
+
export declare const logger: (options?: LoggerOptions) => LoggerPlugin;
|
|
66
|
+
//#endregion
|