shelving 1.176.2 → 1.178.0
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/api/cache/APICache.d.ts +23 -0
- package/api/cache/APICache.js +39 -0
- package/api/cache/EndpointCache.d.ts +26 -0
- package/api/cache/EndpointCache.js +50 -0
- package/api/cache/EndpointStore.d.ts +43 -0
- package/api/cache/EndpointStore.js +117 -0
- package/api/endpoint/Endpoint.d.ts +119 -0
- package/api/endpoint/Endpoint.js +141 -0
- package/api/endpoint/util.d.ts +24 -0
- package/api/endpoint/util.js +61 -0
- package/api/index.d.ts +9 -0
- package/api/index.js +9 -0
- package/api/provider/APIProvider.d.ts +9 -0
- package/api/provider/APIProvider.js +2 -0
- package/api/provider/ClientAPIProvider.d.ts +21 -0
- package/api/provider/ClientAPIProvider.js +18 -0
- package/api/provider/MockAPIProvider.d.ts +34 -0
- package/api/provider/MockAPIProvider.js +24 -0
- package/api/provider/ThroughAPIProvider.d.ts +14 -0
- package/api/provider/ThroughAPIProvider.js +13 -0
- package/error/RequestError.d.ts +4 -0
- package/error/RequestError.js +7 -0
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +4 -4
- package/react/createAPIContext.d.ts +19 -0
- package/react/createAPIContext.js +25 -0
- package/react/index.d.ts +1 -0
- package/react/index.js +1 -0
- package/store/Store.d.ts +13 -3
- package/store/Store.js +22 -8
- package/store/index.d.ts +0 -1
- package/store/index.js +0 -1
- package/util/error.d.ts +7 -0
- package/util/error.js +18 -0
- package/util/http.d.ts +34 -15
- package/util/http.js +50 -28
- package/util/string.js +1 -1
- package/util/url.d.ts +11 -0
- package/util/url.js +22 -0
- package/endpoint/Endpoint.d.ts +0 -123
- package/endpoint/Endpoint.js +0 -178
- package/endpoint/index.d.ts +0 -2
- package/endpoint/index.js +0 -2
- package/endpoint/util.d.ts +0 -26
- package/endpoint/util.js +0 -26
- package/store/EndpointStore.d.ts +0 -24
- package/store/EndpointStore.js +0 -74
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Endpoint } from "../endpoint/Endpoint.js";
|
|
2
|
+
import type { APIProvider } from "../provider/APIProvider.js";
|
|
3
|
+
import { EndpointCache } from "./EndpointCache.js";
|
|
4
|
+
/**
|
|
5
|
+
* Cache of `EndpointCache` objects for multiple endpoints.
|
|
6
|
+
* - Use `get(endpoint)` to retrieve or create the `EndpointCache` for a given endpoint, then `get(payload)` on that to get a specific `EndpointStore`.
|
|
7
|
+
*/
|
|
8
|
+
export declare class APICache implements Disposable {
|
|
9
|
+
private readonly _caches;
|
|
10
|
+
readonly provider: APIProvider;
|
|
11
|
+
constructor(provider: APIProvider);
|
|
12
|
+
/** Get (or create) the `EndpointCache` for the given endpoint. */
|
|
13
|
+
get<P, R>(endpoint: Endpoint<P, R>): EndpointCache<P, R>;
|
|
14
|
+
/** Invalidate a specific store for an endpoint. */
|
|
15
|
+
invalidate<P, R>(endpoint: Endpoint<P, R>, payload: P): void;
|
|
16
|
+
/** Invalidate all stores for an endpoint. */
|
|
17
|
+
invalidateAll<P, R>(endpoint: Endpoint<P, R>): void;
|
|
18
|
+
/** Trigger a refetch on a specific store for an endpoint. */
|
|
19
|
+
refetch<P, R>(endpoint: Endpoint<P, R>, payload: P): void;
|
|
20
|
+
/** Trigger a refetch on all stores for an endpoint. */
|
|
21
|
+
refetchAll<P, R>(endpoint: Endpoint<P, R>): void;
|
|
22
|
+
[Symbol.dispose](): void;
|
|
23
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { setMapItem } from "../../util/map.js";
|
|
2
|
+
import { EndpointCache } from "./EndpointCache.js";
|
|
3
|
+
/**
|
|
4
|
+
* Cache of `EndpointCache` objects for multiple endpoints.
|
|
5
|
+
* - Use `get(endpoint)` to retrieve or create the `EndpointCache` for a given endpoint, then `get(payload)` on that to get a specific `EndpointStore`.
|
|
6
|
+
*/
|
|
7
|
+
export class APICache {
|
|
8
|
+
_caches = new Map();
|
|
9
|
+
provider;
|
|
10
|
+
constructor(provider) {
|
|
11
|
+
this.provider = provider;
|
|
12
|
+
}
|
|
13
|
+
/** Get (or create) the `EndpointCache` for the given endpoint. */
|
|
14
|
+
get(endpoint) {
|
|
15
|
+
return this._caches.get(endpoint) || setMapItem(this._caches, endpoint, new EndpointCache(endpoint, this.provider));
|
|
16
|
+
}
|
|
17
|
+
/** Invalidate a specific store for an endpoint. */
|
|
18
|
+
invalidate(endpoint, payload) {
|
|
19
|
+
this._caches.get(endpoint)?.invalidate(payload);
|
|
20
|
+
}
|
|
21
|
+
/** Invalidate all stores for an endpoint. */
|
|
22
|
+
invalidateAll(endpoint) {
|
|
23
|
+
this._caches.get(endpoint)?.invalidateAll();
|
|
24
|
+
}
|
|
25
|
+
/** Trigger a refetch on a specific store for an endpoint. */
|
|
26
|
+
refetch(endpoint, payload) {
|
|
27
|
+
this._caches.get(endpoint)?.refetch(payload);
|
|
28
|
+
}
|
|
29
|
+
/** Trigger a refetch on all stores for an endpoint. */
|
|
30
|
+
refetchAll(endpoint) {
|
|
31
|
+
this._caches.get(endpoint)?.refetchAll();
|
|
32
|
+
}
|
|
33
|
+
// Implement Disposable.
|
|
34
|
+
[Symbol.dispose]() {
|
|
35
|
+
for (const cache of this._caches.values())
|
|
36
|
+
cache[Symbol.dispose]();
|
|
37
|
+
this._caches.clear();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Endpoint } from "../endpoint/Endpoint.js";
|
|
2
|
+
import type { APIProvider } from "../provider/APIProvider.js";
|
|
3
|
+
import { EndpointStore } from "./EndpointStore.js";
|
|
4
|
+
/**
|
|
5
|
+
* Cache of `EndpointStore` objects for a single endpoint, keyed by serialized payload.
|
|
6
|
+
* - Use `get(payload)` to retrieve or create the `EndpointStore` for a given payload.
|
|
7
|
+
*/
|
|
8
|
+
export declare class EndpointCache<P, R> implements Disposable {
|
|
9
|
+
private readonly _stores;
|
|
10
|
+
readonly endpoint: Endpoint<P, R>;
|
|
11
|
+
readonly provider: APIProvider;
|
|
12
|
+
constructor(endpoint: Endpoint<P, R>, provider: APIProvider);
|
|
13
|
+
/** Get (or create) the `EndpointStore` for the given payload. */
|
|
14
|
+
get(payload: P): EndpointStore<P, R>;
|
|
15
|
+
/** Invalidate a specific store. */
|
|
16
|
+
invalidate(payload: P): void;
|
|
17
|
+
/** Invalidate all stores. */
|
|
18
|
+
invalidateAll(): void;
|
|
19
|
+
/** Trigger a refetch on a specific store. */
|
|
20
|
+
refetch(payload: P): void;
|
|
21
|
+
/** Trigger a refetch on all stores. */
|
|
22
|
+
refetchAll(): void;
|
|
23
|
+
[Symbol.dispose](): void;
|
|
24
|
+
}
|
|
25
|
+
/** Any endpoint cache. */
|
|
26
|
+
export type AnyEndpointCache = EndpointCache<any, any>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { setMapItem } from "../../util/map.js";
|
|
2
|
+
import { EndpointStore } from "./EndpointStore.js";
|
|
3
|
+
/** Serialize a payload to a stable string key for use in a `Map`. */
|
|
4
|
+
function _serializePayload(payload) {
|
|
5
|
+
if (payload === undefined)
|
|
6
|
+
return "";
|
|
7
|
+
return JSON.stringify(payload);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Cache of `EndpointStore` objects for a single endpoint, keyed by serialized payload.
|
|
11
|
+
* - Use `get(payload)` to retrieve or create the `EndpointStore` for a given payload.
|
|
12
|
+
*/
|
|
13
|
+
export class EndpointCache {
|
|
14
|
+
_stores = new Map();
|
|
15
|
+
endpoint;
|
|
16
|
+
provider;
|
|
17
|
+
constructor(endpoint, provider) {
|
|
18
|
+
this.endpoint = endpoint;
|
|
19
|
+
this.provider = provider;
|
|
20
|
+
}
|
|
21
|
+
/** Get (or create) the `EndpointStore` for the given payload. */
|
|
22
|
+
get(payload) {
|
|
23
|
+
const key = _serializePayload(payload);
|
|
24
|
+
return this._stores.get(key) || setMapItem(this._stores, key, new EndpointStore(this.endpoint, payload, this.provider));
|
|
25
|
+
}
|
|
26
|
+
/** Invalidate a specific store. */
|
|
27
|
+
invalidate(payload) {
|
|
28
|
+
this.get(payload)?.invalidate();
|
|
29
|
+
}
|
|
30
|
+
/** Invalidate all stores. */
|
|
31
|
+
invalidateAll() {
|
|
32
|
+
for (const store of this._stores.values())
|
|
33
|
+
store.invalidate();
|
|
34
|
+
}
|
|
35
|
+
/** Trigger a refetch on a specific store. */
|
|
36
|
+
refetch(payload) {
|
|
37
|
+
this.get(payload)?.fetch();
|
|
38
|
+
}
|
|
39
|
+
/** Trigger a refetch on all stores. */
|
|
40
|
+
refetchAll() {
|
|
41
|
+
for (const store of this._stores.values())
|
|
42
|
+
store.fetch();
|
|
43
|
+
}
|
|
44
|
+
// Implement Disposable.
|
|
45
|
+
[Symbol.dispose]() {
|
|
46
|
+
for (const store of this._stores.values())
|
|
47
|
+
store[Symbol.dispose]();
|
|
48
|
+
this._stores.clear();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Store } from "../../store/Store.js";
|
|
2
|
+
import { NONE } from "../../util/constants.js";
|
|
3
|
+
import type { Endpoint } from "../endpoint/Endpoint.js";
|
|
4
|
+
import type { APIProvider } from "../provider/APIProvider.js";
|
|
5
|
+
/**
|
|
6
|
+
* Store object that loads a result from an API endpoint and manages its state.
|
|
7
|
+
*/
|
|
8
|
+
export declare class EndpointStore<P, R> extends Store<R> implements Disposable {
|
|
9
|
+
readonly provider: APIProvider;
|
|
10
|
+
readonly endpoint: Endpoint<P, R>;
|
|
11
|
+
private _payload;
|
|
12
|
+
/**
|
|
13
|
+
* The current payload set for this endpoint.
|
|
14
|
+
* - Value will only change if the new payload is not deeply equal to the current one.
|
|
15
|
+
* - If a new payload is set, it will abort any current in-flight request
|
|
16
|
+
*/
|
|
17
|
+
get payload(): P;
|
|
18
|
+
set payload(next: P);
|
|
19
|
+
get loading(): boolean;
|
|
20
|
+
get value(): R;
|
|
21
|
+
set value(value: R | typeof NONE);
|
|
22
|
+
constructor(endpoint: Endpoint<P, R>, payload: P, provider: APIProvider);
|
|
23
|
+
/** Store the inflight fetch request. */
|
|
24
|
+
private _inflight;
|
|
25
|
+
/**
|
|
26
|
+
* Invalidate this endpoint, so that calls to `this.value` trigger a new fetch immediately.
|
|
27
|
+
*/
|
|
28
|
+
invalidate(): void;
|
|
29
|
+
/**
|
|
30
|
+
* Fetch the result for this endpoint now.
|
|
31
|
+
* - Triggered automatically when someone reads `value` or `loading`
|
|
32
|
+
*/
|
|
33
|
+
fetch(): Promise<void>;
|
|
34
|
+
/** Fetch the result if the current value is older than `maxAge` milliseconds. */
|
|
35
|
+
refreshStale(maxAge: number): void;
|
|
36
|
+
/** Abort any in-flight request now. */
|
|
37
|
+
private abort;
|
|
38
|
+
/** Fetch the result from the API endpoint now. */
|
|
39
|
+
private _fetch;
|
|
40
|
+
[Symbol.dispose](): void;
|
|
41
|
+
}
|
|
42
|
+
/** Any endpoint store. */
|
|
43
|
+
export type AnyEndpointStore = EndpointStore<any, any>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Store } from "../../store/Store.js";
|
|
2
|
+
import { NONE } from "../../util/constants.js";
|
|
3
|
+
import { isDeepEqual } from "../../util/equal.js";
|
|
4
|
+
const _CANCELLED = Symbol("EndpointStore/CANCELLED");
|
|
5
|
+
const _TIMEOUT = Symbol("EndpointStore/TIMEOUT");
|
|
6
|
+
/**
|
|
7
|
+
* Store object that loads a result from an API endpoint and manages its state.
|
|
8
|
+
*/
|
|
9
|
+
export class EndpointStore extends Store {
|
|
10
|
+
provider;
|
|
11
|
+
endpoint;
|
|
12
|
+
_payload;
|
|
13
|
+
/**
|
|
14
|
+
* The current payload set for this endpoint.
|
|
15
|
+
* - Value will only change if the new payload is not deeply equal to the current one.
|
|
16
|
+
* - If a new payload is set, it will abort any current in-flight request
|
|
17
|
+
*/
|
|
18
|
+
get payload() {
|
|
19
|
+
return this._payload;
|
|
20
|
+
}
|
|
21
|
+
set payload(next) {
|
|
22
|
+
const current = this._payload;
|
|
23
|
+
// Did the payload actually change?
|
|
24
|
+
if (!isDeepEqual(current, next)) {
|
|
25
|
+
this._payload = next;
|
|
26
|
+
this.abort(); // Abort any in-flight requst now (as it would've been sent with the previous payload).
|
|
27
|
+
this.fetch();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Override to possibly trigger a fetch when `this.loading` is read.
|
|
31
|
+
// This is because when we check `store.loading` in a component we are signalling intent that we wish to use that value.
|
|
32
|
+
get loading() {
|
|
33
|
+
const loading = super.loading;
|
|
34
|
+
if (loading)
|
|
35
|
+
this.fetch();
|
|
36
|
+
return loading;
|
|
37
|
+
}
|
|
38
|
+
// Override to possibly trigger a fetch when `this.value` is first read.
|
|
39
|
+
// This is because when we check `store.loading` in a component we are signalling intent that we wish to use that value.
|
|
40
|
+
get value() {
|
|
41
|
+
if (super.loading)
|
|
42
|
+
this.fetch();
|
|
43
|
+
return super.value;
|
|
44
|
+
}
|
|
45
|
+
set value(value) {
|
|
46
|
+
super.value = value;
|
|
47
|
+
}
|
|
48
|
+
constructor(endpoint, payload, provider) {
|
|
49
|
+
super(NONE);
|
|
50
|
+
this.endpoint = endpoint;
|
|
51
|
+
this.provider = provider;
|
|
52
|
+
this.payload = payload;
|
|
53
|
+
}
|
|
54
|
+
/** Store the inflight fetch request. */
|
|
55
|
+
_inflight = undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Invalidate this endpoint, so that calls to `this.value` trigger a new fetch immediately.
|
|
58
|
+
*/
|
|
59
|
+
invalidate() {
|
|
60
|
+
this.abort();
|
|
61
|
+
// @todo Implement this by setting the _age_ to `undefined` instead, so that the current value continues to exist but is so old that it triggers a refetch.
|
|
62
|
+
// That means we need to have an age system built into this somehow, we can't just implement it in `useAPI()`
|
|
63
|
+
// Don't know if I like that.....
|
|
64
|
+
this.value = NONE;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Fetch the result for this endpoint now.
|
|
68
|
+
* - Triggered automatically when someone reads `value` or `loading`
|
|
69
|
+
*/
|
|
70
|
+
fetch() {
|
|
71
|
+
// Re-use existing fetch if it exists.
|
|
72
|
+
if (this._inflight)
|
|
73
|
+
return this._inflight.promise;
|
|
74
|
+
// Create a new fetch.
|
|
75
|
+
const abort = new AbortController();
|
|
76
|
+
const promise = this._fetch(abort);
|
|
77
|
+
this._inflight = { abort, promise };
|
|
78
|
+
return promise;
|
|
79
|
+
}
|
|
80
|
+
/** Fetch the result if the current value is older than `maxAge` milliseconds. */
|
|
81
|
+
refreshStale(maxAge) {
|
|
82
|
+
if (this.age > maxAge)
|
|
83
|
+
void this.fetch();
|
|
84
|
+
}
|
|
85
|
+
/** Abort any in-flight request now. */
|
|
86
|
+
abort() {
|
|
87
|
+
if (this._inflight) {
|
|
88
|
+
const { abort } = this._inflight;
|
|
89
|
+
this._inflight = undefined;
|
|
90
|
+
abort.abort(_CANCELLED);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Fetch the result from the API endpoint now. */
|
|
94
|
+
async _fetch(abort) {
|
|
95
|
+
try {
|
|
96
|
+
const value = await this.provider.fetch(this.endpoint, this._payload, { signal: abort.signal });
|
|
97
|
+
this.reason = undefined;
|
|
98
|
+
this.value = value;
|
|
99
|
+
}
|
|
100
|
+
catch (thrown) {
|
|
101
|
+
console.error(thrown);
|
|
102
|
+
if (thrown === _CANCELLED)
|
|
103
|
+
return; // Cancelled on purpose.
|
|
104
|
+
else if (thrown === _TIMEOUT)
|
|
105
|
+
this.reason = "Timed out"; // Request timed out.
|
|
106
|
+
else
|
|
107
|
+
this.reason = thrown;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
this._inflight = undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Implement Disposable.
|
|
114
|
+
[Symbol.dispose]() {
|
|
115
|
+
this.abort();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { type Schema } from "../../schema/Schema.js";
|
|
2
|
+
import type { ImmutableArray } from "../../util/array.js";
|
|
3
|
+
import { type Data } from "../../util/data.js";
|
|
4
|
+
import type { AnyCaller, Arguments } from "../../util/function.js";
|
|
5
|
+
import { type RequestMethod, type RequestOptions, type RequestParams } from "../../util/http.js";
|
|
6
|
+
import type { AbsolutePath } from "../../util/path.js";
|
|
7
|
+
import { type TemplatePlaceholders } from "../../util/template.js";
|
|
8
|
+
import { type PossibleURL } from "../../util/url.js";
|
|
9
|
+
import type { EndpointCallback, EndpointHandler } from "./util.js";
|
|
10
|
+
/**
|
|
11
|
+
* An abstract API resource definition, used to specify types for e.g. serverless functions.
|
|
12
|
+
*
|
|
13
|
+
* @param method The method of the endpoint, e.g. `GET`
|
|
14
|
+
* @param path Endpoint path, possibly including placeholders e.g. `/users/{id}`
|
|
15
|
+
* @param payload A `Schema` for the payload of the endpoint.
|
|
16
|
+
* @param result A `Schema` for the result of the endpoint.
|
|
17
|
+
*/
|
|
18
|
+
export declare class Endpoint<P, R> {
|
|
19
|
+
/** Endpoint method. */
|
|
20
|
+
readonly method: RequestMethod;
|
|
21
|
+
/** Endpoint path, possibly including placeholders e.g. `/users/{id}` */
|
|
22
|
+
readonly path: AbsolutePath;
|
|
23
|
+
/** Endpoint path, possibly including placeholders e.g. `/users/{id}` */
|
|
24
|
+
readonly placeholders: TemplatePlaceholders;
|
|
25
|
+
/** Payload schema. */
|
|
26
|
+
readonly payload: Schema<P>;
|
|
27
|
+
/** Result schema. */
|
|
28
|
+
readonly result: Schema<R>;
|
|
29
|
+
constructor(method: RequestMethod, path: AbsolutePath, payload: Schema<P>, result: Schema<R>);
|
|
30
|
+
/**
|
|
31
|
+
* Render the path for this endpoint with the given payload.
|
|
32
|
+
* - Path might contain `{placeholder}` values that are replaced with values from `payload`.
|
|
33
|
+
*
|
|
34
|
+
* @returns URL string combining `base` with this endpoint's path, with any `{placeholders}` rendered and `?query` params added.
|
|
35
|
+
*
|
|
36
|
+
* @throws {RequiredError} if this endpoint's path has `{placeholders}` but `payload` is not a data object.
|
|
37
|
+
*/
|
|
38
|
+
renderPath(payload: P, caller?: AnyCaller): AbsolutePath;
|
|
39
|
+
/**
|
|
40
|
+
* Create a `Request` that targets this endpoint with a given base URL.
|
|
41
|
+
* - Path `{placeholders}` are rendered from the payload.
|
|
42
|
+
* - For `GET` and `HEAD`, remaining payload fields are appended as `?query` params.
|
|
43
|
+
* - For all other requests, payload is sent as the body.
|
|
44
|
+
*
|
|
45
|
+
* @param base The base URL where this endpoint is targeted. Base URL conversion rules from `getBaseURL()` apply so only the `origin` and `pathname` are kept and always has a trailing slash.
|
|
46
|
+
*
|
|
47
|
+
* @throws {RequiredError} if this endpoint's path has `{placeholders}` but `payload` is not a data object.
|
|
48
|
+
* @throws {RequiredError} if this is a `HEAD` or `GET` request but `payload` is not a data object.
|
|
49
|
+
*/
|
|
50
|
+
getRequest(base: PossibleURL, payload: P, options?: RequestOptions, caller?: AnyCaller): Request;
|
|
51
|
+
/**
|
|
52
|
+
* Match a method/path pair against this endpoint and return any matched `{placeholder}` params.
|
|
53
|
+
*/
|
|
54
|
+
match(method: RequestMethod, path: AbsolutePath, caller?: AnyCaller): RequestParams | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Create an endpoint handler pairing for this endpoint.
|
|
57
|
+
* @param callback The callback function that implements the logic for this endpoint by receiving the payload and returning the response.
|
|
58
|
+
* @returns An `EndpointHandler` object combining this endpoint and the callback into a single typed object.
|
|
59
|
+
*/
|
|
60
|
+
handler<A extends Arguments = []>(callback: EndpointCallback<P, R, A>): EndpointHandler<P, R, A>;
|
|
61
|
+
/**
|
|
62
|
+
* Parse an HTTP `Response` for this endpoint and validate it against the result schema.
|
|
63
|
+
* - Non-2xx responses become `ResponseError`.
|
|
64
|
+
* - Invalid success payloads also become `ResponseError`.
|
|
65
|
+
*/
|
|
66
|
+
parseResponse(response: Response, caller?: AnyCaller): Promise<R>;
|
|
67
|
+
/** Convert to string, e.g. `GET /user/{id}` */
|
|
68
|
+
toString(): string;
|
|
69
|
+
}
|
|
70
|
+
/** Any endpoint. */
|
|
71
|
+
export type AnyEndpoint = Endpoint<any, any>;
|
|
72
|
+
/** List of endpoints. */
|
|
73
|
+
export type Endpoints = ImmutableArray<AnyEndpoint>;
|
|
74
|
+
/** Extract the payload type from a `Endpoint`. */
|
|
75
|
+
export type PayloadType<X extends Endpoint<unknown, unknown>> = X extends Endpoint<infer Y, unknown> ? Y : never;
|
|
76
|
+
/** Extract the result type from a `Endpoint`. */
|
|
77
|
+
export type EndpointType<X extends Endpoint<unknown, unknown>> = X extends Endpoint<unknown, infer Y> ? Y : never;
|
|
78
|
+
/**
|
|
79
|
+
* Represent a HEAD request to a specified path, with validated payload and return types.
|
|
80
|
+
* "The HEAD method requests a representation of the specified resource. Requests using HEAD should only retrieve data and should not contain a request content."
|
|
81
|
+
*/
|
|
82
|
+
export declare function HEAD<P extends Data, R>(path: AbsolutePath, payload?: Schema<P>, result?: Schema<R>): Endpoint<P, R>;
|
|
83
|
+
export declare function HEAD<P extends Data>(path: AbsolutePath, payload: Schema<P>): Endpoint<P, undefined>;
|
|
84
|
+
export declare function HEAD<R>(path: AbsolutePath, payload: undefined, result: Schema<R>): Endpoint<undefined, R>;
|
|
85
|
+
/**
|
|
86
|
+
* Represent a GET request to a specified path, with validated payload and return types.
|
|
87
|
+
* "The GET method requests a representation of the specified resource. Requests using GET should only retrieve data and should not contain a request content."
|
|
88
|
+
*/
|
|
89
|
+
export declare function GET<P extends Data, R>(path: AbsolutePath, payload?: Schema<P>, result?: Schema<R>): Endpoint<P, R>;
|
|
90
|
+
export declare function GET<P extends Data>(path: AbsolutePath, payload: Schema<P>): Endpoint<P, undefined>;
|
|
91
|
+
export declare function GET<R>(path: AbsolutePath, payload: undefined, result: Schema<R>): Endpoint<undefined, R>;
|
|
92
|
+
/**
|
|
93
|
+
* Represent a POST request to a specified path, with validated payload and return types.
|
|
94
|
+
* "The POST method submits an entity to the specified resource, often causing a change in state or side effects on the server.
|
|
95
|
+
*/
|
|
96
|
+
export declare function POST<P, R>(path: AbsolutePath, payload?: Schema<P>, result?: Schema<R>): Endpoint<P, R>;
|
|
97
|
+
export declare function POST<P>(path: AbsolutePath, payload: Schema<P>): Endpoint<P, undefined>;
|
|
98
|
+
export declare function POST<R>(path: AbsolutePath, payload: undefined, result: Schema<R>): Endpoint<undefined, R>;
|
|
99
|
+
/**
|
|
100
|
+
* Represent a PUT request to a specified path, with validated payload and return types.
|
|
101
|
+
* "The PUT method replaces all current representations of the target resource with the request content."
|
|
102
|
+
*/
|
|
103
|
+
export declare function PUT<P, R>(path: AbsolutePath, payload?: Schema<P>, result?: Schema<R>): Endpoint<P, R>;
|
|
104
|
+
export declare function PUT<P>(path: AbsolutePath, payload: Schema<P>): Endpoint<P, undefined>;
|
|
105
|
+
export declare function PUT<R>(path: AbsolutePath, payload: undefined, result: Schema<R>): Endpoint<undefined, R>;
|
|
106
|
+
/**
|
|
107
|
+
* Represent a PATCH request to a specified path, with validated payload and return types.
|
|
108
|
+
* "The PATCH method applies partial modifications to a resource."
|
|
109
|
+
*/
|
|
110
|
+
export declare function PATCH<P, R>(path: AbsolutePath, payload?: Schema<P>, result?: Schema<R>): Endpoint<P, R>;
|
|
111
|
+
export declare function PATCH<P>(path: AbsolutePath, payload: Schema<P>): Endpoint<P, undefined>;
|
|
112
|
+
export declare function PATCH<R>(path: AbsolutePath, payload: undefined, result: Schema<R>): Endpoint<undefined, R>;
|
|
113
|
+
/**
|
|
114
|
+
* Represent a DELETE request to a specified path, with validated payload and return types.
|
|
115
|
+
* "The DELETE method deletes the specified resource."
|
|
116
|
+
*/
|
|
117
|
+
export declare function DELETE<P, R>(path: AbsolutePath, payload?: Schema<P>, result?: Schema<R>): Endpoint<P, R>;
|
|
118
|
+
export declare function DELETE<P>(path: AbsolutePath, payload: Schema<P>): Endpoint<P, undefined>;
|
|
119
|
+
export declare function DELETE<R>(path: AbsolutePath, payload: undefined, result: Schema<R>): Endpoint<undefined, R>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { RequiredError } from "../../error/RequiredError.js";
|
|
2
|
+
import { ResponseError } from "../../error/ResponseError.js";
|
|
3
|
+
import { UNDEFINED } from "../../schema/Schema.js";
|
|
4
|
+
import { isData } from "../../util/data.js";
|
|
5
|
+
import { getMessage } from "../../util/error.js";
|
|
6
|
+
import { getRequest, getResponseContent } from "../../util/http.js";
|
|
7
|
+
import { omitProps } from "../../util/object.js";
|
|
8
|
+
import { getPlaceholders, matchTemplate, renderTemplate } from "../../util/template.js";
|
|
9
|
+
import { requireBaseURL, requireURL } from "../../util/url.js";
|
|
10
|
+
/**
|
|
11
|
+
* An abstract API resource definition, used to specify types for e.g. serverless functions.
|
|
12
|
+
*
|
|
13
|
+
* @param method The method of the endpoint, e.g. `GET`
|
|
14
|
+
* @param path Endpoint path, possibly including placeholders e.g. `/users/{id}`
|
|
15
|
+
* @param payload A `Schema` for the payload of the endpoint.
|
|
16
|
+
* @param result A `Schema` for the result of the endpoint.
|
|
17
|
+
*/
|
|
18
|
+
export class Endpoint {
|
|
19
|
+
/** Endpoint method. */
|
|
20
|
+
method;
|
|
21
|
+
/** Endpoint path, possibly including placeholders e.g. `/users/{id}` */
|
|
22
|
+
path;
|
|
23
|
+
/** Endpoint path, possibly including placeholders e.g. `/users/{id}` */
|
|
24
|
+
placeholders;
|
|
25
|
+
/** Payload schema. */
|
|
26
|
+
payload;
|
|
27
|
+
/** Result schema. */
|
|
28
|
+
result;
|
|
29
|
+
constructor(method, path, payload, result) {
|
|
30
|
+
this.method = method;
|
|
31
|
+
this.path = path;
|
|
32
|
+
this.placeholders = getPlaceholders(path);
|
|
33
|
+
this.payload = payload;
|
|
34
|
+
this.result = result;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Render the path for this endpoint with the given payload.
|
|
38
|
+
* - Path might contain `{placeholder}` values that are replaced with values from `payload`.
|
|
39
|
+
*
|
|
40
|
+
* @returns URL string combining `base` with this endpoint's path, with any `{placeholders}` rendered and `?query` params added.
|
|
41
|
+
*
|
|
42
|
+
* @throws {RequiredError} if this endpoint's path has `{placeholders}` but `payload` is not a data object.
|
|
43
|
+
*/
|
|
44
|
+
renderPath(payload, caller = this.renderPath) {
|
|
45
|
+
// Placeholders.
|
|
46
|
+
if (this.placeholders.length) {
|
|
47
|
+
assertPlaceholderPayload(payload, this, caller);
|
|
48
|
+
return renderTemplate(this.path, payload, caller);
|
|
49
|
+
}
|
|
50
|
+
// No placeholders.
|
|
51
|
+
return this.path;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Create a `Request` that targets this endpoint with a given base URL.
|
|
55
|
+
* - Path `{placeholders}` are rendered from the payload.
|
|
56
|
+
* - For `GET` and `HEAD`, remaining payload fields are appended as `?query` params.
|
|
57
|
+
* - For all other requests, payload is sent as the body.
|
|
58
|
+
*
|
|
59
|
+
* @param base The base URL where this endpoint is targeted. Base URL conversion rules from `getBaseURL()` apply so only the `origin` and `pathname` are kept and always has a trailing slash.
|
|
60
|
+
*
|
|
61
|
+
* @throws {RequiredError} if this endpoint's path has `{placeholders}` but `payload` is not a data object.
|
|
62
|
+
* @throws {RequiredError} if this is a `HEAD` or `GET` request but `payload` is not a data object.
|
|
63
|
+
*/
|
|
64
|
+
getRequest(base, payload, options, caller = this.getRequest) {
|
|
65
|
+
// Render the path into the base URL.
|
|
66
|
+
const url = requireURL(`.${this.renderPath(payload, caller)}`, requireBaseURL(base, undefined, caller), caller).href;
|
|
67
|
+
// Placeholders are rendered into the path so get omitted from the body payload.
|
|
68
|
+
if (this.placeholders.length) {
|
|
69
|
+
assertPlaceholderPayload(payload, this, caller);
|
|
70
|
+
return getRequest(this.method, url, omitProps(payload, ...this.placeholders), options);
|
|
71
|
+
}
|
|
72
|
+
// No placeholders.
|
|
73
|
+
return getRequest(this.method, url, payload, options);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Match a method/path pair against this endpoint and return any matched `{placeholder}` params.
|
|
77
|
+
*/
|
|
78
|
+
match(method, path, caller = this.match) {
|
|
79
|
+
if (method !== this.method)
|
|
80
|
+
return undefined;
|
|
81
|
+
return matchTemplate(this.path, path, caller);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Create an endpoint handler pairing for this endpoint.
|
|
85
|
+
* @param callback The callback function that implements the logic for this endpoint by receiving the payload and returning the response.
|
|
86
|
+
* @returns An `EndpointHandler` object combining this endpoint and the callback into a single typed object.
|
|
87
|
+
*/
|
|
88
|
+
handler(callback) {
|
|
89
|
+
return { endpoint: this, callback };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Parse an HTTP `Response` for this endpoint and validate it against the result schema.
|
|
93
|
+
* - Non-2xx responses become `ResponseError`.
|
|
94
|
+
* - Invalid success payloads also become `ResponseError`.
|
|
95
|
+
*/
|
|
96
|
+
async parseResponse(response, caller = this.parseResponse) {
|
|
97
|
+
const { ok, status } = response;
|
|
98
|
+
const content = await getResponseContent(response, caller);
|
|
99
|
+
if (!ok)
|
|
100
|
+
throw new ResponseError(getMessage(content) ?? `Error ${status}`, { code: status, cause: response, caller });
|
|
101
|
+
try {
|
|
102
|
+
return this.result.validate(content);
|
|
103
|
+
}
|
|
104
|
+
catch (thrown) {
|
|
105
|
+
// Thrown string indicates a validation error in the returned response.
|
|
106
|
+
if (typeof thrown === "string")
|
|
107
|
+
throw new ResponseError(`Invalid result for ${this.toString()}:\n${thrown}`, { endpoint: this, code: 422, caller });
|
|
108
|
+
throw thrown;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Convert to string, e.g. `GET /user/{id}` */
|
|
112
|
+
toString() {
|
|
113
|
+
return `${this.method} ${this.path}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export function HEAD(path, payload = UNDEFINED, result = UNDEFINED) {
|
|
117
|
+
return new Endpoint("HEAD", path, payload, result);
|
|
118
|
+
}
|
|
119
|
+
export function GET(path, payload = UNDEFINED, result = UNDEFINED) {
|
|
120
|
+
return new Endpoint("GET", path, payload, result);
|
|
121
|
+
}
|
|
122
|
+
export function POST(path, payload = UNDEFINED, result = UNDEFINED) {
|
|
123
|
+
return new Endpoint("POST", path, payload, result);
|
|
124
|
+
}
|
|
125
|
+
export function PUT(path, payload = UNDEFINED, result = UNDEFINED) {
|
|
126
|
+
return new Endpoint("PUT", path, payload, result);
|
|
127
|
+
}
|
|
128
|
+
export function PATCH(path, payload = UNDEFINED, result = UNDEFINED) {
|
|
129
|
+
return new Endpoint("PATCH", path, payload, result);
|
|
130
|
+
}
|
|
131
|
+
export function DELETE(path, payload = UNDEFINED, result = UNDEFINED) {
|
|
132
|
+
return new Endpoint("DELETE", path, payload, result);
|
|
133
|
+
}
|
|
134
|
+
function assertPlaceholderPayload(payload, endpoint, caller = assertPlaceholderPayload) {
|
|
135
|
+
if (!isData(payload))
|
|
136
|
+
throw new RequiredError("Payload for request with URL {placeholders} must be data object", {
|
|
137
|
+
endpoint,
|
|
138
|
+
received: payload,
|
|
139
|
+
caller,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Arguments } from "../../util/function.js";
|
|
2
|
+
import { type PossibleURL } from "../../util/url.js";
|
|
3
|
+
import type { Endpoint } from "./Endpoint.js";
|
|
4
|
+
/**
|
|
5
|
+
* A function that handles an endpoint request, with a payload and returns a result.
|
|
6
|
+
* - `payload` has already been validated against the endpoint payload schema before the callback is invoked.
|
|
7
|
+
* - `request` is always the original incoming request object.
|
|
8
|
+
*/
|
|
9
|
+
export type EndpointCallback<P, R, A extends Arguments = []> = (payload: P, request: Request, ...args: A) => R | Response | Promise<R | Response>;
|
|
10
|
+
/** A typed endpoint definition paired with its implementation callback. */
|
|
11
|
+
export interface EndpointHandler<P = unknown, R = unknown, A extends Arguments = []> {
|
|
12
|
+
readonly endpoint: Endpoint<P, R>;
|
|
13
|
+
readonly callback: EndpointCallback<P, R, A>;
|
|
14
|
+
}
|
|
15
|
+
export type AnyEndpointHandler<A extends Arguments = []> = EndpointHandler<any, any, A>;
|
|
16
|
+
/** A collection of endpoint handlers that can be matched and invoked by `handleEndpoints()`. */
|
|
17
|
+
export type EndpointHandlers<A extends Arguments = []> = Iterable<AnyEndpointHandler<A>>;
|
|
18
|
+
/**
|
|
19
|
+
* Handle a `Request` with the first matching endpoint handler after stripping any base-path prefix from the request pathname.
|
|
20
|
+
* - The original `Request` object is passed through to the callback unchanged.
|
|
21
|
+
* - Path params and query params are merged before payload validation.
|
|
22
|
+
* @param base The base URL for the API, e.g. `https://myapi.com/`
|
|
23
|
+
*/
|
|
24
|
+
export declare function handleEndpoints<A extends Arguments = []>(request: Request, base: PossibleURL, handlers: EndpointHandlers<A>, ...args: A): Promise<Response>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { MethodNotAllowedError, NotFoundError } from "../../error/RequestError.js";
|
|
2
|
+
import { ValueError } from "../../error/ValueError.js";
|
|
3
|
+
import { getDictionary } from "../../util/dictionary.js";
|
|
4
|
+
import { getRequestContent, getResponse, isRequestMethod } from "../../util/http.js";
|
|
5
|
+
import { isPlainObject } from "../../util/object.js";
|
|
6
|
+
import { requireURL } from "../../util/url.js";
|
|
7
|
+
/**
|
|
8
|
+
* Handle a `Request` with the first matching endpoint handler after stripping any base-path prefix from the request pathname.
|
|
9
|
+
* - The original `Request` object is passed through to the callback unchanged.
|
|
10
|
+
* - Path params and query params are merged before payload validation.
|
|
11
|
+
* @param base The base URL for the API, e.g. `https://myapi.com/`
|
|
12
|
+
*/
|
|
13
|
+
export function handleEndpoints(request, base, handlers, ...args) {
|
|
14
|
+
const caller = handleEndpoints;
|
|
15
|
+
const { url, method } = request;
|
|
16
|
+
if (!isRequestMethod(method))
|
|
17
|
+
throw new MethodNotAllowedError("Unsupported request method", { received: method, caller });
|
|
18
|
+
const { origin: baseOrigin, pathname: basePath } = requireURL(base, undefined, caller);
|
|
19
|
+
const { origin: requestOrigin, pathname: requestPath, searchParams } = requireURL(url, base, caller);
|
|
20
|
+
if (baseOrigin !== requestOrigin)
|
|
21
|
+
throw new NotFoundError("No matching origin", { expected: baseOrigin, received: requestOrigin, caller });
|
|
22
|
+
const targetPath = _stripPathPrefix(requestPath, basePath, caller);
|
|
23
|
+
for (const handler of handlers) {
|
|
24
|
+
const pathParams = handler.endpoint.match(method, targetPath, caller);
|
|
25
|
+
if (!pathParams)
|
|
26
|
+
continue;
|
|
27
|
+
const params = searchParams.size ? { ...getDictionary(searchParams), ...pathParams } : pathParams;
|
|
28
|
+
return handleEndpoint(handler, params, request, args, handleEndpoints);
|
|
29
|
+
}
|
|
30
|
+
throw new NotFoundError("No matching endpoint", { received: targetPath, caller });
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Validate and invoke an endpoint callback after the routing layer has already matched URL params.
|
|
34
|
+
*/
|
|
35
|
+
async function handleEndpoint({ endpoint, callback },
|
|
36
|
+
/** Params we already matched/parsed from the URL. */
|
|
37
|
+
params, request, args, caller = handleEndpoint) {
|
|
38
|
+
const content = await getRequestContent(request, caller);
|
|
39
|
+
const unsafePayload = content === undefined ? params : isPlainObject(content) ? { ...content, ...params } : content;
|
|
40
|
+
const payload = endpoint.payload.validate(unsafePayload);
|
|
41
|
+
const unsafeResult = await callback(payload, request, ...args);
|
|
42
|
+
try {
|
|
43
|
+
return getResponse(endpoint.result.validate(unsafeResult));
|
|
44
|
+
}
|
|
45
|
+
catch (thrown) {
|
|
46
|
+
if (typeof thrown === "string")
|
|
47
|
+
throw new ValueError(`Invalid result for ${endpoint.toString()}:\n${thrown}`, { endpoint, callback, cause: thrown, caller });
|
|
48
|
+
throw thrown;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Strip a prefix like `/a/b` from a path like `/a/b/c/d` to produce a remainder path like `/c/d`. */
|
|
52
|
+
function _stripPathPrefix(path, prefix, caller) {
|
|
53
|
+
prefix = prefix === "/" ? "/" : prefix.replace(/\/$/, "");
|
|
54
|
+
if (prefix === "/")
|
|
55
|
+
return path;
|
|
56
|
+
if (path === prefix)
|
|
57
|
+
return "/";
|
|
58
|
+
if (path.startsWith(`${prefix}/`))
|
|
59
|
+
return path.slice(prefix.length);
|
|
60
|
+
throw new NotFoundError("No matching endpoint", { received: path, expected: prefix, caller });
|
|
61
|
+
}
|
package/api/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./cache/APICache.js";
|
|
2
|
+
export * from "./cache/EndpointCache.js";
|
|
3
|
+
export * from "./cache/EndpointStore.js";
|
|
4
|
+
export * from "./endpoint/Endpoint.js";
|
|
5
|
+
export * from "./endpoint/util.js";
|
|
6
|
+
export * from "./provider/APIProvider.js";
|
|
7
|
+
export * from "./provider/ClientAPIProvider.js";
|
|
8
|
+
export * from "./provider/MockAPIProvider.js";
|
|
9
|
+
export * from "./provider/ThroughAPIProvider.js";
|