shelving 1.182.1 → 1.183.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/README.md +1 -1
- package/api/provider/APIProvider.d.ts +23 -6
- package/api/provider/APIProvider.js +25 -8
- package/api/provider/MockAPIProvider.d.ts +7 -10
- package/api/provider/MockAPIProvider.js +9 -4
- package/api/provider/MockEndpointAPIProvider.d.ts +3 -9
- package/api/provider/MockEndpointAPIProvider.js +2 -2
- package/api/provider/ThroughAPIProvider.d.ts +1 -0
- package/api/provider/ThroughAPIProvider.js +3 -0
- package/package.json +6 -6
- package/util/http.d.ts +2 -1
- package/util/http.js +5 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Shelving
|
|
2
2
|
|
|
3
|
-
[](https://conventionalcommits.org) [](https://conventionalcommits.org) [](https://github.com/dhoulb/shelving/actions/workflows/release.yaml) [](https://www.npmjs.com/package/shelving)
|
|
4
4
|
|
|
5
5
|
Shelving is a TypeScript toolkit for working with typed data. At its core it is a schema validation library — every schema has a `validate()` method that returns a typed value or throws a human-readable error. On top of that it provides a database provider abstraction, an API provider abstraction, observable state stores, React integration, and a large set of typed utility functions.
|
|
6
6
|
|
|
@@ -6,8 +6,13 @@ import type { Endpoint } from "../endpoint/Endpoint.js";
|
|
|
6
6
|
export interface APIProviderOptions {
|
|
7
7
|
/** The common base URL for all rendered endpoint requests. */
|
|
8
8
|
readonly url: PossibleURL;
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Options used for HTTP requests created with `this.getRequest()` and `this.fetch()`
|
|
11
|
+
* - Omits `signal` because it's not relevant at the provider level.
|
|
12
|
+
*/
|
|
13
|
+
readonly options?: Omit<RequestOptions, "signal">;
|
|
14
|
+
/** Timeout in milliseconds, or `undefined` for no timeout. */
|
|
15
|
+
readonly timeout?: number | undefined;
|
|
11
16
|
}
|
|
12
17
|
/** Provider for API endpoints rooted at a common base URL. */
|
|
13
18
|
export declare class APIProvider {
|
|
@@ -15,7 +20,9 @@ export declare class APIProvider {
|
|
|
15
20
|
readonly url: URLString;
|
|
16
21
|
/** Default options used for HTTP requests created with `this.getRequest()` and `this.fetch()` */
|
|
17
22
|
readonly options: RequestOptions;
|
|
18
|
-
|
|
23
|
+
/** Timeout in milliseconds, or `undefined` for no timeout. */
|
|
24
|
+
readonly timeout: number | undefined;
|
|
25
|
+
constructor({ url, options, timeout }: APIProviderOptions);
|
|
19
26
|
/**
|
|
20
27
|
* Render the full final URL for an API request to a given endpoint with a given payload.
|
|
21
28
|
* - Includes `?query` params if this is a `HEAD` or `GET` request.
|
|
@@ -26,9 +33,19 @@ export declare class APIProvider {
|
|
|
26
33
|
renderURL<P, R>(endpoint: Endpoint<P, R>, payload: P, caller?: AnyCaller): URL;
|
|
27
34
|
/**
|
|
28
35
|
* Create a `Request` that targets this endpoint with a given base URL.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* -
|
|
36
|
+
*
|
|
37
|
+
* @param payload The payload to embed into the `Request` to send to the endpoint.
|
|
38
|
+
* - Path `{placeholders}` are rendered from `payload`
|
|
39
|
+
* - For `GET` and `HEAD`, remaining `payload` fields are appended as `?query` params.
|
|
40
|
+
* - For all other requests, `payload` is sent as the body.
|
|
41
|
+
*
|
|
42
|
+
* @param options The `RequestOptions` to use when creating the `Request`
|
|
43
|
+
* - Merges `options` with `this.options` to make the final request options.
|
|
44
|
+
*
|
|
45
|
+
* @returns The created request.
|
|
46
|
+
* - Merges `options` with `this.options` to make the final request options.
|
|
47
|
+
* - Includes an `AbortSignal` based on `this.timeout` if it's set to a number in milliseconds.
|
|
48
|
+
* - The timeout `AbortSignal` is merged with any manual signal set in `
|
|
32
49
|
*
|
|
33
50
|
* @throws {RequiredError} if this endpoint's path has `{placeholders}` but `payload` is not a data object.
|
|
34
51
|
* @throws {RequiredError} if this is a `HEAD` or `GET` request but `payload` is not a data object.
|
|
@@ -11,9 +11,12 @@ export class APIProvider {
|
|
|
11
11
|
url;
|
|
12
12
|
/** Default options used for HTTP requests created with `this.getRequest()` and `this.fetch()` */
|
|
13
13
|
options;
|
|
14
|
-
|
|
14
|
+
/** Timeout in milliseconds, or `undefined` for no timeout. */
|
|
15
|
+
timeout;
|
|
16
|
+
constructor({ url, options = {}, timeout }) {
|
|
15
17
|
this.url = requireBaseURL(url, undefined, APIProvider);
|
|
16
18
|
this.options = options;
|
|
19
|
+
this.timeout = timeout;
|
|
17
20
|
}
|
|
18
21
|
/**
|
|
19
22
|
* Render the full final URL for an API request to a given endpoint with a given payload.
|
|
@@ -36,9 +39,19 @@ export class APIProvider {
|
|
|
36
39
|
}
|
|
37
40
|
/**
|
|
38
41
|
* Create a `Request` that targets this endpoint with a given base URL.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* -
|
|
42
|
+
*
|
|
43
|
+
* @param payload The payload to embed into the `Request` to send to the endpoint.
|
|
44
|
+
* - Path `{placeholders}` are rendered from `payload`
|
|
45
|
+
* - For `GET` and `HEAD`, remaining `payload` fields are appended as `?query` params.
|
|
46
|
+
* - For all other requests, `payload` is sent as the body.
|
|
47
|
+
*
|
|
48
|
+
* @param options The `RequestOptions` to use when creating the `Request`
|
|
49
|
+
* - Merges `options` with `this.options` to make the final request options.
|
|
50
|
+
*
|
|
51
|
+
* @returns The created request.
|
|
52
|
+
* - Merges `options` with `this.options` to make the final request options.
|
|
53
|
+
* - Includes an `AbortSignal` based on `this.timeout` if it's set to a number in milliseconds.
|
|
54
|
+
* - The timeout `AbortSignal` is merged with any manual signal set in `
|
|
42
55
|
*
|
|
43
56
|
* @throws {RequiredError} if this endpoint's path has `{placeholders}` but `payload` is not a data object.
|
|
44
57
|
* @throws {RequiredError} if this is a `HEAD` or `GET` request but `payload` is not a data object.
|
|
@@ -46,17 +59,21 @@ export class APIProvider {
|
|
|
46
59
|
getRequest(endpoint, payload, options, caller = this.getRequest) {
|
|
47
60
|
// Render the path into the base URL.
|
|
48
61
|
const url = this.renderURL(endpoint, payload, caller);
|
|
62
|
+
// Merge the param options with `this.options`
|
|
63
|
+
// If we have a timeout set, create an `AbortSignal` for it.
|
|
64
|
+
const signal = this.timeout ? AbortSignal.timeout(this.timeout) : null;
|
|
65
|
+
const mergedOptions = mergeRequestOptions({ signal, ...this.options }, options);
|
|
49
66
|
// HEAD or GET requests have no payload because it was already rendered into the URL as `?query` params.
|
|
50
67
|
if (isArrayItem(HTTP_HEAD_METHODS, endpoint.method)) {
|
|
51
|
-
return getRequest(endpoint.method, url, undefined,
|
|
68
|
+
return getRequest(endpoint.method, url, undefined, mergedOptions);
|
|
52
69
|
}
|
|
53
70
|
// Placeholders are rendered into the path so get omitted from the body payload.
|
|
54
71
|
if (endpoint.placeholders.length) {
|
|
55
72
|
const params = omitProps(payload, ...endpoint.placeholders); // Omit any params that were already embedded as `{placeholders}`
|
|
56
|
-
return getRequest(endpoint.method, url, params,
|
|
73
|
+
return getRequest(endpoint.method, url, params, mergedOptions);
|
|
57
74
|
}
|
|
58
75
|
// No placeholders.
|
|
59
|
-
return getRequest(endpoint.method, url, payload,
|
|
76
|
+
return getRequest(endpoint.method, url, payload, mergedOptions);
|
|
60
77
|
}
|
|
61
78
|
/**
|
|
62
79
|
* Parse an HTTP `Response` for this endpoint.
|
|
@@ -71,7 +88,7 @@ export class APIProvider {
|
|
|
71
88
|
return content;
|
|
72
89
|
}
|
|
73
90
|
async fetch(endpoint, payload, options, caller = this.fetch) {
|
|
74
|
-
const request = this.getRequest(endpoint, payload,
|
|
91
|
+
const request = this.getRequest(endpoint, payload, options, caller);
|
|
75
92
|
const response = await fetch(request);
|
|
76
93
|
return this.parseResponse(endpoint, response, caller);
|
|
77
94
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AnyCaller } from "../../util/function.js";
|
|
2
2
|
import { type RequestHandler, type RequestOptions } from "../../util/http.js";
|
|
3
3
|
import type { AnyEndpoint, Endpoint } from "../endpoint/Endpoint.js";
|
|
4
|
-
import { APIProvider
|
|
4
|
+
import { APIProvider } from "./APIProvider.js";
|
|
5
|
+
import { ThroughAPIProvider } from "./ThroughAPIProvider.js";
|
|
5
6
|
/** A structured log entry emitted by `MockAPIProvider` for one of its provider operations. */
|
|
6
7
|
export type MockAPICall = {
|
|
7
8
|
readonly type: "fetch";
|
|
@@ -13,18 +14,14 @@ export type MockAPICall = {
|
|
|
13
14
|
readonly result: unknown;
|
|
14
15
|
};
|
|
15
16
|
/**
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
17
|
+
* Provider that logs API calls without sending network requests.
|
|
18
|
+
* - Extends `ThroughAPIProvider` to delegate request building and response parsing to a source `APIProvider`.
|
|
19
|
+
* - The source provider's `fetch()` is never called — this provider intercepts all fetches and routes them through a `RequestHandler`.
|
|
18
20
|
*/
|
|
19
|
-
export
|
|
20
|
-
/** Optional URL, defaults to `"https://api.mock.com"` */
|
|
21
|
-
url?: APIProviderOptions["url"];
|
|
22
|
-
}
|
|
23
|
-
/** Provider that logs API calls without sending network requests. */
|
|
24
|
-
export declare class MockAPIProvider extends APIProvider {
|
|
21
|
+
export declare class MockAPIProvider extends ThroughAPIProvider {
|
|
25
22
|
readonly calls: MockAPICall[];
|
|
26
23
|
readonly handler: RequestHandler;
|
|
27
|
-
constructor(handler: RequestHandler,
|
|
24
|
+
constructor(handler: RequestHandler, source?: APIProvider);
|
|
28
25
|
/**
|
|
29
26
|
* Log a `fetch()` call without using the network.
|
|
30
27
|
* - If `getResult` is configured, its return value is returned as-is (no schema validation).
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { mergeRequestOptions } from "../../util/http.js";
|
|
2
2
|
import { APIProvider } from "./APIProvider.js";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { ThroughAPIProvider } from "./ThroughAPIProvider.js";
|
|
4
|
+
/**
|
|
5
|
+
* Provider that logs API calls without sending network requests.
|
|
6
|
+
* - Extends `ThroughAPIProvider` to delegate request building and response parsing to a source `APIProvider`.
|
|
7
|
+
* - The source provider's `fetch()` is never called — this provider intercepts all fetches and routes them through a `RequestHandler`.
|
|
8
|
+
*/
|
|
9
|
+
export class MockAPIProvider extends ThroughAPIProvider {
|
|
5
10
|
calls = [];
|
|
6
11
|
handler;
|
|
7
|
-
constructor(handler, { url
|
|
8
|
-
super(
|
|
12
|
+
constructor(handler, source = new APIProvider({ url: "https://api.mock.com" })) {
|
|
13
|
+
super(source);
|
|
9
14
|
this.handler = handler;
|
|
10
15
|
}
|
|
11
16
|
/**
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import { type EndpointHandlers } from "../endpoint/util.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
* Construction options for a `MockAPIProvider`
|
|
5
|
-
* - Same as options for a normal `MockAPIProviderOptions`, but with a `context` property for the endpoints.
|
|
6
|
-
*/
|
|
7
|
-
export interface MockEndpointAPIProviderOptions<C = void> extends MockAPIProviderOptions {
|
|
8
|
-
context: C;
|
|
9
|
-
}
|
|
2
|
+
import type { APIProvider } from "./APIProvider.js";
|
|
3
|
+
import { MockAPIProvider } from "./MockAPIProvider.js";
|
|
10
4
|
/**
|
|
11
5
|
* Provider that mocks an API that calls and matches an array of `EndpointHandler` objects returned from `Endpoint.handler()`
|
|
12
6
|
* - Used to test server-side API code, calls against an API made up of multiple `Endpoint` instances.
|
|
@@ -19,5 +13,5 @@ export interface MockEndpointAPIProviderOptions<C = void> extends MockAPIProvide
|
|
|
19
13
|
* expect(result).toBe(16);
|
|
20
14
|
*/
|
|
21
15
|
export declare class MockEndpointAPIProvider<C> extends MockAPIProvider {
|
|
22
|
-
constructor(handlers: EndpointHandlers<C>,
|
|
16
|
+
constructor(handlers: EndpointHandlers<C>, context: C, source?: APIProvider);
|
|
23
17
|
}
|
|
@@ -12,7 +12,7 @@ import { MockAPIProvider } from "./MockAPIProvider.js";
|
|
|
12
12
|
* expect(result).toBe(16);
|
|
13
13
|
*/
|
|
14
14
|
export class MockEndpointAPIProvider extends MockAPIProvider {
|
|
15
|
-
constructor(handlers,
|
|
16
|
-
super(request => handleEndpoints(this.url, handlers, request, context),
|
|
15
|
+
constructor(handlers, context, source) {
|
|
16
|
+
super(request => handleEndpoints(this.url, handlers, request, context, this.fetch), source);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
@@ -11,6 +11,7 @@ export declare class ThroughAPIProvider implements APIProvider {
|
|
|
11
11
|
readonly source: APIProvider;
|
|
12
12
|
get url(): URLString;
|
|
13
13
|
get options(): RequestOptions;
|
|
14
|
+
get timeout(): number | undefined;
|
|
14
15
|
constructor(source: APIProvider);
|
|
15
16
|
renderURL<P, R>(endpoint: Endpoint<P, R>, payload: P, caller?: AnyCaller): URL;
|
|
16
17
|
getRequest<P, R>(endpoint: Endpoint<P, R>, payload: P, options?: RequestOptions, caller?: AnyCaller): Request;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shelving",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.183.0",
|
|
4
4
|
"author": "Dave Houlbrooke <dave@shax.com>",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
"main": "./index.js",
|
|
10
10
|
"module": "./index.js",
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"@biomejs/biome": "^2.4.
|
|
12
|
+
"@biomejs/biome": "^2.4.11",
|
|
13
13
|
"@google-cloud/firestore": "^8.3.0",
|
|
14
|
-
"@types/bun": "^1.3.
|
|
14
|
+
"@types/bun": "^1.3.12",
|
|
15
15
|
"@types/react": "^19.2.14",
|
|
16
16
|
"@types/react-dom": "^19.2.3",
|
|
17
|
-
"firebase": "^12.
|
|
18
|
-
"react": "^19.2.
|
|
19
|
-
"react-dom": "^19.2.
|
|
17
|
+
"firebase": "^12.12.0",
|
|
18
|
+
"react": "^19.2.5",
|
|
19
|
+
"react-dom": "^19.2.5",
|
|
20
20
|
"typescript": "^5.9.3"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
package/util/http.d.ts
CHANGED
|
@@ -79,8 +79,9 @@ export type RequestOptions = Pick<RequestInit, "cache" | "credentials" | "header
|
|
|
79
79
|
* Merge provider-level and call-level request options.
|
|
80
80
|
* - Scalar options from `b` override `a`.
|
|
81
81
|
* - Header dictionaries are merged so call-level headers override default headers by key.
|
|
82
|
+
* - Abort signals are merged, so either abort signal will cancel the request.
|
|
82
83
|
*/
|
|
83
|
-
export declare function mergeRequestOptions({ headers: aHeaders, ...a }?: RequestOptions, { headers: bHeaders, ...b }?: RequestOptions): RequestOptions;
|
|
84
|
+
export declare function mergeRequestOptions({ headers: aHeaders, signal: aSignal, ...a }?: RequestOptions, { headers: bHeaders, signal: bSignal, ...b }?: RequestOptions): RequestOptions;
|
|
84
85
|
/**
|
|
85
86
|
* Create a `Request` instance with a valid content type based on the body.
|
|
86
87
|
*
|
package/util/http.js
CHANGED
|
@@ -135,9 +135,12 @@ const REQUEST_JSON_OPTIONS = { headers: { "Content-Type": "application/json" } }
|
|
|
135
135
|
* Merge provider-level and call-level request options.
|
|
136
136
|
* - Scalar options from `b` override `a`.
|
|
137
137
|
* - Header dictionaries are merged so call-level headers override default headers by key.
|
|
138
|
+
* - Abort signals are merged, so either abort signal will cancel the request.
|
|
138
139
|
*/
|
|
139
|
-
export function mergeRequestOptions({ headers: aHeaders, ...a } = {}, { headers: bHeaders, ...b } = {}) {
|
|
140
|
-
|
|
140
|
+
export function mergeRequestOptions({ headers: aHeaders, signal: aSignal, ...a } = {}, { headers: bHeaders, signal: bSignal, ...b } = {}) {
|
|
141
|
+
const headers = { ...aHeaders, ...bHeaders };
|
|
142
|
+
const signal = aSignal && bSignal ? AbortSignal.any([aSignal, bSignal]) : aSignal || bSignal || null;
|
|
143
|
+
return { ...a, ...b, signal, headers };
|
|
141
144
|
}
|
|
142
145
|
/**
|
|
143
146
|
* Create a `Request` instance with a valid content type based on the body.
|