shelving 1.183.0 → 1.184.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.
@@ -5,20 +5,20 @@ import { EndpointCache } from "./EndpointCache.js";
5
5
  * Cache of `EndpointCache` objects for multiple endpoints.
6
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
7
  */
8
- export declare class APICache implements Disposable {
8
+ export declare class APICache<P, R> implements Disposable {
9
9
  private readonly _caches;
10
- readonly provider: APIProvider;
11
- constructor(provider: APIProvider);
10
+ readonly provider: APIProvider<P, R>;
11
+ constructor(provider: APIProvider<P, R>);
12
12
  private _get;
13
13
  /** Get (or create) the `EndpointCache` for the given endpoint. */
14
- get<P, R>(endpoint: Endpoint<P, R>): EndpointCache<P, R>;
14
+ get<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>): EndpointCache<PP, RR>;
15
15
  /** Invalidate a specific store for an endpoint. */
16
- invalidate<P, R>(endpoint: Endpoint<P, R>, payload: P): void;
16
+ invalidate<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP): void;
17
17
  /** Invalidate all stores for an endpoint. */
18
- invalidateAll<P, R>(endpoint: Endpoint<P, R>): void;
18
+ invalidateAll<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>): void;
19
19
  /** Trigger a refetch on a specific store for an endpoint. */
20
- refetch<P, R>(endpoint: Endpoint<P, R>, payload: P): void;
20
+ refetch<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP): void;
21
21
  /** Trigger a refetch on all stores for an endpoint. */
22
- refetchAll<P, R>(endpoint: Endpoint<P, R>): void;
22
+ refetchAll<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>): void;
23
23
  [Symbol.dispose](): void;
24
24
  }
@@ -9,8 +9,8 @@ import { EndpointStore } from "../store/EndpointStore.js";
9
9
  export declare class EndpointCache<P = unknown, R = unknown> implements Disposable {
10
10
  private readonly _stores;
11
11
  readonly endpoint: Endpoint<P, R>;
12
- readonly provider: APIProvider;
13
- constructor(endpoint: Endpoint<P, R>, provider: APIProvider);
12
+ readonly provider: APIProvider<P, R>;
13
+ constructor(endpoint: Endpoint<P, R>, provider: APIProvider<P, R>);
14
14
  /** Get (or create) the `EndpointStore` for the given payload. */
15
15
  get(payload: P, caller?: AnyCaller): EndpointStore<P, R>;
16
16
  /** Invalidate a specific store. */
@@ -1,7 +1,7 @@
1
1
  import { MethodNotAllowedError, NotFoundError } from "../../error/RequestError.js";
2
2
  import { ValueError } from "../../error/ValueError.js";
3
3
  import { getDictionary } from "../../util/dictionary.js";
4
- import { getRequestContent, getResponse, isRequestMethod } from "../../util/http.js";
4
+ import { getResponse, isRequestMethod, parseRequestBody } from "../../util/http.js";
5
5
  import { isPlainObject } from "../../util/object.js";
6
6
  import { requireURL } from "../../util/url.js";
7
7
  export function handleEndpoints(base, handlers, request, context, caller = handleEndpoints) {
@@ -30,7 +30,7 @@ export function handleEndpoints(base, handlers, request, context, caller = handl
30
30
  async function _handleEndpoint({ endpoint, callback },
31
31
  /** Params we already matched/parsed from the URL. */
32
32
  params, request, context, caller) {
33
- const content = await getRequestContent(request, caller);
33
+ const content = await parseRequestBody(request, caller);
34
34
  const unsafePayload = content === undefined ? params : isPlainObject(content) ? { ...content, ...params } : content;
35
35
  const payload = endpoint.payload.validate(unsafePayload);
36
36
  const unsafeResult = await callback(payload, request, context);
package/api/index.d.ts CHANGED
@@ -3,9 +3,12 @@ export * from "./cache/EndpointCache.js";
3
3
  export * from "./endpoint/Endpoint.js";
4
4
  export * from "./endpoint/util.js";
5
5
  export * from "./provider/APIProvider.js";
6
+ export * from "./provider/ClientAPIProvider.js";
6
7
  export * from "./provider/DebugAPIProvider.js";
8
+ export * from "./provider/JSONAPIProvider.js";
7
9
  export * from "./provider/MockAPIProvider.js";
8
10
  export * from "./provider/MockEndpointAPIProvider.js";
9
11
  export * from "./provider/ThroughAPIProvider.js";
10
12
  export * from "./provider/ValidationAPIProvider.js";
13
+ export * from "./provider/XMLAPIProvider.js";
11
14
  export * from "./store/EndpointStore.js";
package/api/index.js CHANGED
@@ -3,9 +3,12 @@ export * from "./cache/EndpointCache.js";
3
3
  export * from "./endpoint/Endpoint.js";
4
4
  export * from "./endpoint/util.js";
5
5
  export * from "./provider/APIProvider.js";
6
+ export * from "./provider/ClientAPIProvider.js";
6
7
  export * from "./provider/DebugAPIProvider.js";
8
+ export * from "./provider/JSONAPIProvider.js";
7
9
  export * from "./provider/MockAPIProvider.js";
8
10
  export * from "./provider/MockEndpointAPIProvider.js";
9
11
  export * from "./provider/ThroughAPIProvider.js";
10
12
  export * from "./provider/ValidationAPIProvider.js";
13
+ export * from "./provider/XMLAPIProvider.js";
11
14
  export * from "./store/EndpointStore.js";
@@ -1,28 +1,11 @@
1
1
  import type { AnyCaller } from "../../util/function.js";
2
- import { type RequestOptions } from "../../util/http.js";
3
- import { type PossibleURL, type URL, type URLString } from "../../util/url.js";
2
+ import type { RequestOptions } from "../../util/http.js";
3
+ import type { URL, URLString } from "../../util/url.js";
4
4
  import type { Endpoint } from "../endpoint/Endpoint.js";
5
- /** Options for an `APIProvider`. */
6
- export interface APIProviderOptions {
7
- /** The common base URL for all rendered endpoint requests. */
8
- readonly url: PossibleURL;
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;
16
- }
17
5
  /** Provider for API endpoints rooted at a common base URL. */
18
- export declare class APIProvider {
19
- /** The common base URL for all rendered endpoint requests. */
20
- readonly url: URLString;
21
- /** Default options used for HTTP requests created with `this.getRequest()` and `this.fetch()` */
22
- readonly options: RequestOptions;
23
- /** Timeout in milliseconds, or `undefined` for no timeout. */
24
- readonly timeout: number | undefined;
25
- constructor({ url, options, timeout }: APIProviderOptions);
6
+ export declare abstract class APIProvider<P = unknown, R = unknown> {
7
+ /** The base URL for this API. */
8
+ abstract readonly url: URLString;
26
9
  /**
27
10
  * Render the full final URL for an API request to a given endpoint with a given payload.
28
11
  * - Includes `?query` params if this is a `HEAD` or `GET` request.
@@ -30,7 +13,7 @@ export declare class APIProvider {
30
13
  * @throws {RequiredError} if this endpoint's path has `{placeholders}` but `payload` is not a data object.
31
14
  * @throws {RequiredError} if this is a `HEAD` or `GET` request but `payload` is not a data object.
32
15
  */
33
- renderURL<P, R>(endpoint: Endpoint<P, R>, payload: P, caller?: AnyCaller): URL;
16
+ abstract renderURL<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, caller?: AnyCaller): URL;
34
17
  /**
35
18
  * Create a `Request` that targets this endpoint with a given base URL.
36
19
  *
@@ -50,12 +33,13 @@ export declare class APIProvider {
50
33
  * @throws {RequiredError} if this endpoint's path has `{placeholders}` but `payload` is not a data object.
51
34
  * @throws {RequiredError} if this is a `HEAD` or `GET` request but `payload` is not a data object.
52
35
  */
53
- getRequest<P, R>(endpoint: Endpoint<P, R>, payload: P, options?: RequestOptions, caller?: AnyCaller): Request;
36
+ abstract getRequest<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, options?: RequestOptions, caller?: AnyCaller): Request;
54
37
  /**
55
38
  * Parse an HTTP `Response` for this endpoint.
56
39
  * - Non-2xx responses become `ResponseError`.
57
40
  * - Does not validate the result against the endpoint schema — use `ValidationAPIProvider` for that.
58
41
  */
59
- parseResponse<P, R>(_endpoint: Endpoint<P, R>, response: Response, caller?: AnyCaller): Promise<R>;
60
- fetch<P, R>(endpoint: Endpoint<P, R>, payload: P, options?: RequestOptions, caller?: AnyCaller): Promise<R>;
42
+ abstract parseResponse<PP extends P, RR extends R>(_endpoint: Endpoint<PP, RR>, response: Response, caller?: AnyCaller): Promise<RR>;
43
+ /** Send a payload to an `Endpoint` and retrieve the result. */
44
+ abstract fetch<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, options?: RequestOptions, caller?: AnyCaller): Promise<RR>;
61
45
  }
@@ -1,95 +1,3 @@
1
- import { ResponseError } from "../../error/ResponseError.js";
2
- import { isArrayItem } from "../../util/array.js";
3
- import { getMessage } from "../../util/error.js";
4
- import { assertHeadMethodPayload, getRequest, getResponseContent, HTTP_HEAD_METHODS, mergeRequestOptions, } from "../../util/http.js";
5
- import { omitProps } from "../../util/object.js";
6
- import { withURIParams } from "../../util/uri.js";
7
- import { requireBaseURL, requireURL } from "../../util/url.js";
8
1
  /** Provider for API endpoints rooted at a common base URL. */
9
2
  export class APIProvider {
10
- /** The common base URL for all rendered endpoint requests. */
11
- url;
12
- /** Default options used for HTTP requests created with `this.getRequest()` and `this.fetch()` */
13
- options;
14
- /** Timeout in milliseconds, or `undefined` for no timeout. */
15
- timeout;
16
- constructor({ url, options = {}, timeout }) {
17
- this.url = requireBaseURL(url, undefined, APIProvider);
18
- this.options = options;
19
- this.timeout = timeout;
20
- }
21
- /**
22
- * Render the full final URL for an API request to a given endpoint with a given payload.
23
- * - Includes `?query` params if this is a `HEAD` or `GET` request.
24
- *
25
- * @throws {RequiredError} if this endpoint's path has `{placeholders}` but `payload` is not a data object.
26
- * @throws {RequiredError} if this is a `HEAD` or `GET` request but `payload` is not a data object.
27
- */
28
- renderURL(endpoint, payload, caller = this.renderURL) {
29
- const url = requireURL(`.${endpoint.renderPath(payload, caller)}`, this.url, caller);
30
- // HEAD or GET have no body (but payload can only be data object).
31
- if (isArrayItem(HTTP_HEAD_METHODS, endpoint.method)) {
32
- assertHeadMethodPayload(payload, endpoint.method, caller);
33
- if (payload) {
34
- const params = endpoint.placeholders.length ? omitProps(payload, ...endpoint.placeholders) : payload; // Omit any params that were already embedded as `{placeholders}`
35
- return withURIParams(url, params, caller);
36
- }
37
- }
38
- return url;
39
- }
40
- /**
41
- * Create a `Request` that targets this endpoint with a given base URL.
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 `
55
- *
56
- * @throws {RequiredError} if this endpoint's path has `{placeholders}` but `payload` is not a data object.
57
- * @throws {RequiredError} if this is a `HEAD` or `GET` request but `payload` is not a data object.
58
- */
59
- getRequest(endpoint, payload, options, caller = this.getRequest) {
60
- // Render the path into the base URL.
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);
66
- // HEAD or GET requests have no payload because it was already rendered into the URL as `?query` params.
67
- if (isArrayItem(HTTP_HEAD_METHODS, endpoint.method)) {
68
- return getRequest(endpoint.method, url, undefined, mergedOptions);
69
- }
70
- // Placeholders are rendered into the path so get omitted from the body payload.
71
- if (endpoint.placeholders.length) {
72
- const params = omitProps(payload, ...endpoint.placeholders); // Omit any params that were already embedded as `{placeholders}`
73
- return getRequest(endpoint.method, url, params, mergedOptions);
74
- }
75
- // No placeholders.
76
- return getRequest(endpoint.method, url, payload, mergedOptions);
77
- }
78
- /**
79
- * Parse an HTTP `Response` for this endpoint.
80
- * - Non-2xx responses become `ResponseError`.
81
- * - Does not validate the result against the endpoint schema — use `ValidationAPIProvider` for that.
82
- */
83
- async parseResponse(_endpoint, response, caller = this.parseResponse) {
84
- const { ok, status } = response;
85
- const content = await getResponseContent(response, caller);
86
- if (!ok)
87
- throw new ResponseError(getMessage(content) ?? `Error ${status}`, { code: status, cause: response, caller });
88
- return content;
89
- }
90
- async fetch(endpoint, payload, options, caller = this.fetch) {
91
- const request = this.getRequest(endpoint, payload, options, caller);
92
- const response = await fetch(request);
93
- return this.parseResponse(endpoint, response, caller);
94
- }
95
3
  }
@@ -0,0 +1,38 @@
1
+ import type { AnyCaller } from "../../util/function.js";
2
+ import { type RequestBodyMethod, type RequestHeadMethod, type RequestOptions } from "../../util/http.js";
3
+ import type { Nullish } from "../../util/null.js";
4
+ import { type PossibleURIParams } from "../../util/uri.js";
5
+ import { type PossibleURL, type URL, type URLString } from "../../util/url.js";
6
+ import type { Endpoint } from "../endpoint/Endpoint.js";
7
+ import type { APIProvider } from "./APIProvider.js";
8
+ export declare class ClientAPIProvider<P = unknown, R = unknown> implements APIProvider<P, R> {
9
+ /** The common base URL for all rendered endpoint requests. */
10
+ readonly url: URLString;
11
+ /** Default options used for HTTP requests created with `this.getRequest()` and `this.fetch()` */
12
+ readonly options: RequestOptions;
13
+ /** Timeout in milliseconds, or `undefined` for no timeout. */
14
+ readonly timeout: number | undefined;
15
+ constructor({ url, options, timeout }: ClientAPIProviderOptions);
16
+ renderURL<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, caller?: AnyCaller): URL;
17
+ getRequest<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, options?: RequestOptions, caller?: AnyCaller): Request;
18
+ /** Internal implementation function for `getRequest()` used for requests that have no body. */
19
+ protected _getHeadRequest(method: RequestHeadMethod, //
20
+ url: PossibleURL, params: Nullish<PossibleURIParams>, options: RequestOptions, caller: AnyCaller): Request;
21
+ /** Internal implementation function for `getRequest()` used for requests that have a body. */
22
+ protected _getBodyRequest(method: RequestBodyMethod, //
23
+ url: PossibleURL, payload: P, options: RequestOptions, caller: AnyCaller): Request;
24
+ parseResponse<PP extends P, RR extends R>(_endpoint: Endpoint<PP, RR>, response: Response, caller?: AnyCaller): Promise<RR>;
25
+ fetch<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, options?: RequestOptions, caller?: AnyCaller): Promise<RR>;
26
+ }
27
+ /** Options for an `APIProvider`. */
28
+ export interface ClientAPIProviderOptions {
29
+ /** The common base URL for all rendered endpoint requests. */
30
+ readonly url: PossibleURL;
31
+ /**
32
+ * Options used for HTTP requests created with `this.getRequest()` and `this.fetch()`
33
+ * - Omits `signal` because it's not relevant at the provider level.
34
+ */
35
+ readonly options?: Omit<RequestOptions, "signal">;
36
+ /** Timeout in milliseconds, or `undefined` for no timeout. */
37
+ readonly timeout?: number | undefined;
38
+ }
@@ -0,0 +1,68 @@
1
+ import { ResponseError } from "../../error/ResponseError.js";
2
+ import { isData } from "../../util/data.js";
3
+ import { getMessage } from "../../util/error.js";
4
+ import { assertRequestHeadPayload, getHeadRequest, getRequest, isRequestHeadMethod, mergeRequestOptions, parseResponseBody, } from "../../util/http.js";
5
+ import { omitProps } from "../../util/object.js";
6
+ import { withURIParams } from "../../util/uri.js";
7
+ import { requireBaseURL, requireURL } from "../../util/url.js";
8
+ export class ClientAPIProvider {
9
+ /** The common base URL for all rendered endpoint requests. */
10
+ url;
11
+ /** Default options used for HTTP requests created with `this.getRequest()` and `this.fetch()` */
12
+ options;
13
+ /** Timeout in milliseconds, or `undefined` for no timeout. */
14
+ timeout;
15
+ constructor({ url, options = {}, timeout }) {
16
+ this.url = requireBaseURL(url, undefined, ClientAPIProvider);
17
+ this.options = options;
18
+ this.timeout = timeout;
19
+ }
20
+ renderURL(endpoint, payload, caller = this.renderURL) {
21
+ const url = requireURL(`.${endpoint.renderPath(payload, caller)}`, this.url, caller);
22
+ // HEAD or GET have no body (but payload can only be data object).
23
+ if (isRequestHeadMethod(endpoint.method)) {
24
+ assertRequestHeadPayload(payload, endpoint.method, caller);
25
+ if (payload) {
26
+ const params = endpoint.placeholders.length ? omitProps(payload, ...endpoint.placeholders) : payload; // Omit any params that have already been embedded as `{placeholders}`.
27
+ return withURIParams(url, params, caller);
28
+ }
29
+ }
30
+ return url;
31
+ }
32
+ getRequest(endpoint, payload, options, caller = this.getRequest) {
33
+ // Render the path into the base URL.
34
+ const url = this.renderURL(endpoint, payload, caller);
35
+ // Merge the param options with `this.options`
36
+ // If we have a timeout set, create an `AbortSignal` for it.
37
+ const signal = this.timeout ? AbortSignal.timeout(this.timeout) : null;
38
+ const mergedOptions = mergeRequestOptions({ signal, ...this.options }, options);
39
+ // HEAD or GET requests need no payload because it was already rendered into the URL as `?query` params by `this.renderURL()`
40
+ if (isRequestHeadMethod(endpoint.method))
41
+ return this._getHeadRequest(endpoint.method, url, undefined, mergedOptions, caller);
42
+ // Body request.
43
+ const body = isData(payload) ? omitProps(payload, ...endpoint.placeholders) : payload; // Omit any params that have already been embedded as `{placeholders}`.
44
+ return this._getBodyRequest(endpoint.method, url, body, mergedOptions, caller);
45
+ }
46
+ /** Internal implementation function for `getRequest()` used for requests that have no body. */
47
+ _getHeadRequest(method, //
48
+ url, params, options, caller) {
49
+ return getHeadRequest(method, url, params, options, caller);
50
+ }
51
+ /** Internal implementation function for `getRequest()` used for requests that have a body. */
52
+ _getBodyRequest(method, //
53
+ url, payload, options, caller) {
54
+ return getRequest(method, url, payload, options, caller);
55
+ }
56
+ async parseResponse(_endpoint, response, caller = this.parseResponse) {
57
+ const { ok, status } = response;
58
+ const content = await parseResponseBody(response, caller);
59
+ if (!ok)
60
+ throw new ResponseError(getMessage(content) ?? `Error ${status}`, { code: status, cause: response, caller });
61
+ return content;
62
+ }
63
+ async fetch(endpoint, payload, options, caller = this.fetch) {
64
+ const request = this.getRequest(endpoint, payload, options, caller);
65
+ const response = await fetch(request);
66
+ return this.parseResponse(endpoint, response, caller);
67
+ }
68
+ }
@@ -3,6 +3,6 @@ import type { RequestOptions } from "../../util/http.js";
3
3
  import type { Endpoint } from "../endpoint/Endpoint.js";
4
4
  import { ThroughAPIProvider } from "./ThroughAPIProvider.js";
5
5
  /** Provider that logs API operations to the console. */
6
- export declare class DebugAPIProvider extends ThroughAPIProvider {
7
- fetch<P, R>(endpoint: Endpoint<P, R>, payload: P, options?: RequestOptions, caller?: AnyCaller): Promise<R>;
6
+ export declare class DebugAPIProvider<P, R> extends ThroughAPIProvider<P, R> {
7
+ fetch<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, options?: RequestOptions, caller?: AnyCaller): Promise<RR>;
8
8
  }
@@ -0,0 +1,16 @@
1
+ import type { AnyCaller } from "../../util/function.js";
2
+ import { type RequestBodyMethod, type RequestOptions } from "../../util/http.js";
3
+ import type { PossibleURL } from "../../util/url.js";
4
+ import type { Endpoint } from "../endpoint/Endpoint.js";
5
+ import { ClientAPIProvider } from "./ClientAPIProvider.js";
6
+ /** API provider that always sends request bodies as JSON and parses responses as JSON. */
7
+ export declare class JSONAPIProvider<P = unknown, R = unknown> extends ClientAPIProvider<P, R> {
8
+ protected _getBodyRequest(method: RequestBodyMethod, url: PossibleURL, payload: P, options: RequestOptions, caller: AnyCaller): Request;
9
+ /**
10
+ * Parse a JSON `Response` for an endpoint.
11
+ *
12
+ * - Non-2xx responses become `ResponseError`.
13
+ * - JSON is parsed even if the server omitted or mis-set the response content type.
14
+ */
15
+ parseResponse<PP extends P, RR extends R>(_endpoint: Endpoint<PP, RR>, response: Response, caller?: AnyCaller): Promise<RR>;
16
+ }
@@ -0,0 +1,23 @@
1
+ import { ResponseError } from "../../error/ResponseError.js";
2
+ import { getMessage } from "../../util/error.js";
3
+ import { getJSONRequest, parseResponseJSON } from "../../util/http.js";
4
+ import { ClientAPIProvider } from "./ClientAPIProvider.js";
5
+ /** API provider that always sends request bodies as JSON and parses responses as JSON. */
6
+ export class JSONAPIProvider extends ClientAPIProvider {
7
+ _getBodyRequest(method, url, payload, options, caller) {
8
+ return getJSONRequest(method, url, payload, options, caller);
9
+ }
10
+ /**
11
+ * Parse a JSON `Response` for an endpoint.
12
+ *
13
+ * - Non-2xx responses become `ResponseError`.
14
+ * - JSON is parsed even if the server omitted or mis-set the response content type.
15
+ */
16
+ async parseResponse(_endpoint, response, caller = this.parseResponse) {
17
+ const { ok, status } = response;
18
+ const content = await parseResponseJSON(response, caller);
19
+ if (!ok)
20
+ throw new ResponseError(getMessage(content) ?? `Error ${status}`, { code: status, cause: response, caller });
21
+ return content;
22
+ }
23
+ }
@@ -1,13 +1,12 @@
1
1
  import type { AnyCaller } from "../../util/function.js";
2
- import { type RequestHandler, type RequestOptions } from "../../util/http.js";
2
+ import type { RequestHandler, RequestOptions } from "../../util/http.js";
3
3
  import type { AnyEndpoint, Endpoint } from "../endpoint/Endpoint.js";
4
- import { APIProvider } from "./APIProvider.js";
4
+ import { ClientAPIProvider } from "./ClientAPIProvider.js";
5
5
  import { ThroughAPIProvider } from "./ThroughAPIProvider.js";
6
6
  /** A structured log entry emitted by `MockAPIProvider` for one of its provider operations. */
7
7
  export type MockAPICall = {
8
8
  readonly type: "fetch";
9
9
  readonly endpoint: AnyEndpoint;
10
- readonly options: RequestOptions;
11
10
  readonly payload: unknown;
12
11
  readonly request: Request;
13
12
  readonly response: Response;
@@ -18,14 +17,9 @@ export type MockAPICall = {
18
17
  * - Extends `ThroughAPIProvider` to delegate request building and response parsing to a source `APIProvider`.
19
18
  * - The source provider's `fetch()` is never called — this provider intercepts all fetches and routes them through a `RequestHandler`.
20
19
  */
21
- export declare class MockAPIProvider extends ThroughAPIProvider {
20
+ export declare class MockAPIProvider<P = unknown, R = unknown> extends ThroughAPIProvider<P, R> {
22
21
  readonly calls: MockAPICall[];
23
22
  readonly handler: RequestHandler;
24
- constructor(handler: RequestHandler, source?: APIProvider);
25
- /**
26
- * Log a `fetch()` call without using the network.
27
- * - If `getResult` is configured, its return value is returned as-is (no schema validation).
28
- * - Otherwise `undefined` is returned.
29
- */
30
- fetch<P, R>(endpoint: Endpoint<P, R>, payload: P, _options?: RequestOptions, caller?: AnyCaller): Promise<R>;
23
+ constructor(handler?: RequestHandler, source?: ClientAPIProvider<P, R>);
24
+ fetch<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, options?: RequestOptions, caller?: AnyCaller): Promise<RR>;
31
25
  }
@@ -1,6 +1,9 @@
1
- import { mergeRequestOptions } from "../../util/http.js";
2
- import { APIProvider } from "./APIProvider.js";
1
+ import { ClientAPIProvider } from "./ClientAPIProvider.js";
3
2
  import { ThroughAPIProvider } from "./ThroughAPIProvider.js";
3
+ /** Default handler just echoes back the input request as text. */
4
+ async function _passthroughHandler(request) {
5
+ return new Response(await request.text());
6
+ }
4
7
  /**
5
8
  * Provider that logs API calls without sending network requests.
6
9
  * - Extends `ThroughAPIProvider` to delegate request building and response parsing to a source `APIProvider`.
@@ -9,21 +12,16 @@ import { ThroughAPIProvider } from "./ThroughAPIProvider.js";
9
12
  export class MockAPIProvider extends ThroughAPIProvider {
10
13
  calls = [];
11
14
  handler;
12
- constructor(handler, source = new APIProvider({ url: "https://api.mock.com" })) {
15
+ constructor(handler = _passthroughHandler, source = new ClientAPIProvider({ url: "https://api.mock.com" })) {
13
16
  super(source);
14
17
  this.handler = handler;
15
18
  }
16
- /**
17
- * Log a `fetch()` call without using the network.
18
- * - If `getResult` is configured, its return value is returned as-is (no schema validation).
19
- * - Otherwise `undefined` is returned.
20
- */
21
- async fetch(endpoint, payload, _options = {}, caller = this.fetch) {
22
- const options = mergeRequestOptions(this.options, _options);
19
+ // Log a `fetch()` call without using the network.
20
+ async fetch(endpoint, payload, options = {}, caller = this.fetch) {
23
21
  const request = this.getRequest(endpoint, payload, options, caller);
24
22
  const response = await this.handler(request);
25
23
  const result = await this.parseResponse(endpoint, response, caller);
26
- this.calls.push({ type: "fetch", endpoint, payload, options, request, response, result });
24
+ this.calls.push({ type: "fetch", endpoint, payload, request, response, result });
27
25
  return result;
28
26
  }
29
27
  }
@@ -1,5 +1,5 @@
1
1
  import { type EndpointHandlers } from "../endpoint/util.js";
2
- import type { APIProvider } from "./APIProvider.js";
2
+ import type { ClientAPIProvider } from "./ClientAPIProvider.js";
3
3
  import { MockAPIProvider } from "./MockAPIProvider.js";
4
4
  /**
5
5
  * Provider that mocks an API that calls and matches an array of `EndpointHandler` objects returned from `Endpoint.handler()`
@@ -12,6 +12,6 @@ import { MockAPIProvider } from "./MockAPIProvider.js";
12
12
  * const result = await api.fetch(endpoint, 4); // Mock a call to the endpoint through the provider.
13
13
  * expect(result).toBe(16);
14
14
  */
15
- export declare class MockEndpointAPIProvider<C> extends MockAPIProvider {
16
- constructor(handlers: EndpointHandlers<C>, context: C, source?: APIProvider);
15
+ export declare class MockEndpointAPIProvider<P, R, C> extends MockAPIProvider<P, R> {
16
+ constructor(handlers: EndpointHandlers<C>, context: C, source?: ClientAPIProvider<P, R>);
17
17
  }
@@ -1,5 +1,6 @@
1
1
  import type { AnyCaller } from "../../util/function.js";
2
2
  import type { RequestOptions } from "../../util/http.js";
3
+ import type { Sourceable } from "../../util/source.js";
3
4
  import type { URL, URLString } from "../../util/url.js";
4
5
  import type { Endpoint } from "../endpoint/Endpoint.js";
5
6
  import type { APIProvider } from "./APIProvider.js";
@@ -7,14 +8,12 @@ import type { APIProvider } from "./APIProvider.js";
7
8
  * Provider wrapper that delegates API operations to a source provider.
8
9
  * - Extend this when you want to intercept only selected API operations, such as injecting auth headers or logging.
9
10
  */
10
- export declare class ThroughAPIProvider implements APIProvider {
11
- readonly source: APIProvider;
11
+ export declare class ThroughAPIProvider<P, R> implements APIProvider<P, R>, Sourceable<APIProvider<P, R>> {
12
12
  get url(): URLString;
13
- get options(): RequestOptions;
14
- get timeout(): number | undefined;
15
- constructor(source: APIProvider);
16
- renderURL<P, R>(endpoint: Endpoint<P, R>, payload: P, caller?: AnyCaller): URL;
17
- getRequest<P, R>(endpoint: Endpoint<P, R>, payload: P, options?: RequestOptions, caller?: AnyCaller): Request;
18
- parseResponse<P, R>(endpoint: Endpoint<P, R>, response: Response, caller?: AnyCaller): Promise<R>;
19
- fetch<P, R>(endpoint: Endpoint<P, R>, payload: P, options?: RequestOptions, caller?: AnyCaller): Promise<R>;
13
+ readonly source: APIProvider<P, R>;
14
+ constructor(source: APIProvider<P, R>);
15
+ renderURL<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, caller?: AnyCaller): URL;
16
+ getRequest<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, options?: RequestOptions, caller?: AnyCaller): Request;
17
+ parseResponse<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, response: Response, caller?: AnyCaller): Promise<RR>;
18
+ fetch<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, options?: RequestOptions, caller?: AnyCaller): Promise<RR>;
20
19
  }
@@ -3,16 +3,10 @@
3
3
  * - Extend this when you want to intercept only selected API operations, such as injecting auth headers or logging.
4
4
  */
5
5
  export class ThroughAPIProvider {
6
- source;
7
6
  get url() {
8
7
  return this.source.url;
9
8
  }
10
- get options() {
11
- return this.source.options;
12
- }
13
- get timeout() {
14
- return this.source.timeout;
15
- }
9
+ source;
16
10
  constructor(source) {
17
11
  this.source = source;
18
12
  }
@@ -3,6 +3,6 @@ import type { RequestOptions } from "../../util/http.js";
3
3
  import type { Endpoint } from "../endpoint/Endpoint.js";
4
4
  import { ThroughAPIProvider } from "./ThroughAPIProvider.js";
5
5
  /** Validate an asynchronous source provider (source can have any type because validation guarantees the type). */
6
- export declare class ValidationAPIProvider extends ThroughAPIProvider {
7
- fetch<P, R>(endpoint: Endpoint<P, R>, payload: P, options?: RequestOptions, caller?: AnyCaller): Promise<R>;
6
+ export declare class ValidationAPIProvider<P, R> extends ThroughAPIProvider<P, R> {
7
+ fetch<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, options?: RequestOptions, caller?: AnyCaller): Promise<RR>;
8
8
  }
@@ -4,16 +4,13 @@ import { ThroughAPIProvider } from "./ThroughAPIProvider.js";
4
4
  export class ValidationAPIProvider extends ThroughAPIProvider {
5
5
  async fetch(endpoint, payload, options, caller = this.fetch) {
6
6
  // Validate payload — let thrown strings bubble up as user-readable messages for e.g. form handlers.
7
- const validPayload = _validatePayload(endpoint, payload);
7
+ const validPayload = endpoint.payload.validate(payload);
8
8
  // Call through to source (raw transport, no schema validation).
9
9
  const content = await this.source.fetch(endpoint, validPayload, options, caller);
10
10
  // Validate result — wrap in ResponseError as this is a server/transport problem, not user error.
11
11
  return _validateResult(endpoint, content, caller);
12
12
  }
13
13
  }
14
- function _validatePayload(endpoint, payload) {
15
- return endpoint.payload.validate(payload);
16
- }
17
14
  function _validateResult(endpoint, content, caller) {
18
15
  try {
19
16
  return endpoint.result.validate(content);
@@ -0,0 +1,17 @@
1
+ import type { Data } from "../../util/data.js";
2
+ import type { AnyCaller } from "../../util/function.js";
3
+ import { type RequestBodyMethod, type RequestOptions } from "../../util/http.js";
4
+ import type { PossibleURL } from "../../util/url.js";
5
+ import type { Endpoint } from "../endpoint/Endpoint.js";
6
+ import { ClientAPIProvider } from "./ClientAPIProvider.js";
7
+ /** API provider that always sends request bodies as XML and parses responses as plain text. */
8
+ export declare class XMLAPIProvider<P extends Data = Data, R = string> extends ClientAPIProvider<P, R> {
9
+ protected _getBodyRequest(method: RequestBodyMethod, url: PossibleURL, payload: P, options: RequestOptions, caller: AnyCaller): Request;
10
+ /**
11
+ * Parse a text `Response` for an endpoint.
12
+ *
13
+ * - Non-2xx responses become `ResponseError`.
14
+ * - The response body is always returned as raw text.
15
+ */
16
+ parseResponse<PP extends P, RR extends R>(_endpoint: Endpoint<PP, RR>, response: Response, caller?: AnyCaller): Promise<RR>;
17
+ }
@@ -0,0 +1,22 @@
1
+ import { ResponseError } from "../../error/ResponseError.js";
2
+ import { getXMLRequest } from "../../util/http.js";
3
+ import { ClientAPIProvider } from "./ClientAPIProvider.js";
4
+ /** API provider that always sends request bodies as XML and parses responses as plain text. */
5
+ export class XMLAPIProvider extends ClientAPIProvider {
6
+ _getBodyRequest(method, url, payload, options, caller) {
7
+ return getXMLRequest(method, url, payload, options, caller);
8
+ }
9
+ /**
10
+ * Parse a text `Response` for an endpoint.
11
+ *
12
+ * - Non-2xx responses become `ResponseError`.
13
+ * - The response body is always returned as raw text.
14
+ */
15
+ async parseResponse(_endpoint, response, caller = this.parseResponse) {
16
+ const { ok, status } = response;
17
+ const content = await response.text();
18
+ if (!ok)
19
+ throw new ResponseError(content || `Error ${status}`, { code: status, cause: response, caller });
20
+ return content;
21
+ }
22
+ }
@@ -6,7 +6,7 @@ import type { APIProvider } from "../provider/APIProvider.js";
6
6
  * Store object that loads a result from an API endpoint and manages its state.
7
7
  */
8
8
  export declare class EndpointStore<P, R> extends Store<R> implements Disposable {
9
- readonly provider: APIProvider;
9
+ readonly provider: APIProvider<P, R>;
10
10
  readonly endpoint: Endpoint<P, R>;
11
11
  private _payload;
12
12
  /**
@@ -19,7 +19,7 @@ export declare class EndpointStore<P, R> extends Store<R> implements Disposable
19
19
  get loading(): boolean;
20
20
  get value(): R;
21
21
  set value(value: R | typeof NONE);
22
- constructor(endpoint: Endpoint<P, R>, payload: P, provider: APIProvider);
22
+ constructor(endpoint: Endpoint<P, R>, payload: P, provider: APIProvider<P, R>);
23
23
  /** Store the inflight fetch request. */
24
24
  private _inflight;
25
25
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.183.0",
3
+ "version": "1.184.1",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3,10 +3,11 @@ import type { Endpoint } from "../api/endpoint/Endpoint.js";
3
3
  import type { APIProvider } from "../api/provider/APIProvider.js";
4
4
  import type { EndpointStore } from "../api/store/EndpointStore.js";
5
5
  import type { Nullish } from "../util/null.js";
6
- export interface APIContext {
6
+ export interface APIContext<P, R> {
7
7
  /** Get an `EndpointStore` for the specified endpoint/payload in the current `APIProvider` context. */
8
- useAPI<P, R>(this: void, endpoint: Endpoint<P, R>, payload: P, maxAge?: number): EndpointStore<P, R>;
9
- useAPI<P, R>(this: void, endpoint: Nullish<Endpoint<P, R>>, payload: P, maxAge?: number): EndpointStore<P, R> | undefined;
8
+ useAPI<PP extends P, RR extends R>(this: void, endpoint: Endpoint<PP, RR>, payload: PP, maxAge?: number): EndpointStore<PP, RR>;
9
+ useAPI<PP extends P, RR extends R>(this: void, endpoint: Nullish<Endpoint<PP, RR>>, payload: PP, maxAge?: number): EndpointStore<PP, RR> | undefined;
10
+ /** The `<APIContext>` wrapper to give your React components access to this API provider. */
10
11
  readonly APIContext: ({ children }: {
11
12
  children: ReactNode;
12
13
  }) => ReactElement;
@@ -18,4 +19,4 @@ export interface APIContext {
18
19
  *
19
20
  * @todo Use and integreate our `EndpointCache` functionality and use it in this.
20
21
  */
21
- export declare function createAPIContext(provider: APIProvider): APIContext;
22
+ export declare function createAPIContext<P, R>(provider: APIProvider<P, R>): APIContext<P, R>;
package/util/http.d.ts CHANGED
@@ -1,9 +1,8 @@
1
- import { RequestError } from "../error/RequestError.js";
2
- import { ResponseError } from "../error/ResponseError.js";
3
1
  import { type Data } from "./data.js";
4
2
  import type { ImmutableDictionary } from "./dictionary.js";
5
3
  import type { AnyCaller, Arguments } from "./function.js";
6
4
  import { type Nullish } from "./null.js";
5
+ import { type PossibleURIParams } from "./uri.js";
7
6
  import { type PossibleURL } from "./url.js";
8
7
  /** A handler function takes a `Request` and optional extra arguments and returns a `Response` (possibly asynchronously). */
9
8
  export type RequestHandler<A extends Arguments = []> = (request: Request, ...args: A) => Response | Promise<Response>;
@@ -11,11 +10,8 @@ export type RequestHandler<A extends Arguments = []> = (request: Request, ...arg
11
10
  export type OptionalRequestHandler<A extends Arguments = []> = (request: Request, ...args: A) => Response | Promise<Response> | undefined;
12
11
  /** A list of optional request handlers. */
13
12
  export type OptionalRequestHandlers<A extends Arguments = []> = Iterable<OptionalRequestHandler<A>>;
14
- export declare function _getMessageJSON(message: Request | Response, MessageError: typeof RequestError | typeof ResponseError, caller: AnyCaller): Promise<unknown>;
15
- export declare function _getMessageFormData(message: Request | Response, MessageError: typeof RequestError | typeof ResponseError, caller: AnyCaller): Promise<unknown>;
16
- export declare function _getMessageContent(message: Request | Response, MessageError: typeof RequestError | typeof ResponseError, caller: AnyCaller): Promise<unknown>;
17
13
  /**
18
- * Get the body content of an HTTP `Request` based on its content type, or throw `RequestError` if the content could not be parsed.
14
+ * Parse the body content of an HTTP `Request` based on its content type, or throw `RequestError` if the content could not be parsed.
19
15
  *
20
16
  * @returns undefined If the request method is `GET` or `HEAD` (these request methods have no body).
21
17
  * @returns unknown If content type is `application/json` and has valid JSON (including `undefined` if the content is empty).
@@ -24,18 +20,41 @@ export declare function _getMessageContent(message: Request | Response, MessageE
24
20
  *
25
21
  * @throws RequestError if the content is not `text/plain`, or `application/json` with valid JSON.
26
22
  */
27
- export declare function getRequestContent(request: Request, caller?: AnyCaller): Promise<unknown>;
23
+ export declare function parseRequestBody(request: Request, caller?: AnyCaller): Promise<unknown>;
28
24
  /**
29
- * Get the body content of an HTTP `Response` based on its content type, or throw `ResponseError` if the content could not be parsed.
25
+ * Parse JSON from an HTTP `Request`, or return `undefined` when the request has no body.
26
+ *
27
+ * @throws RequestError If the request body is not valid JSON.
28
+ */
29
+ export declare function parseRequestJSON(request: Request, caller?: AnyCaller): Promise<unknown>;
30
+ /**
31
+ * Parse `FormData` from an HTTP `Request`, or return `undefined` when the request has no body.
32
+ *
33
+ * @throws RequestError If the request body is not valid multipart form-data.
34
+ */
35
+ export declare function parseRequestFormData(request: Request, caller?: AnyCaller): Promise<FormData | undefined>;
36
+ /**
37
+ * Parse the body content of an HTTP `Response` based on its content type, or throw `ResponseError` if the content could not be parsed.
30
38
  *
31
- * @returns undefined If the request status is `204 No Content` (this response has no body).
32
39
  * @returns unknown If content type is `application/json` and has valid JSON (including `undefined` if the content is empty).
33
40
  * @returns unknown If content type is `multipart/form-data` then convert it to a simple `Data` object.
34
41
  * @returns string If content type is `text/plain` or anything else (including `""` empty string if it's empty).
35
42
  *
36
- * @throws RequestError if the content is not `text/plain` or `application/json` with valid JSON.
43
+ * @throws ResponseError if the content is not `text/plain` or `application/json` with valid JSON.
44
+ */
45
+ export declare function parseResponseBody(response: Response, caller?: AnyCaller): Promise<unknown>;
46
+ /**
47
+ * Parse JSON from an HTTP `Response`, or return `undefined` when the response has no body.
48
+ *
49
+ * @throws ResponseError If the response body is not valid JSON.
37
50
  */
38
- export declare function getResponseContent(response: Response, caller?: AnyCaller): Promise<unknown>;
51
+ export declare function parseResponseJSON(response: Response, caller?: AnyCaller): Promise<unknown>;
52
+ /**
53
+ * Parse `FormData` from an HTTP `Response`, or return `undefined` when the response has no body.
54
+ *
55
+ * @throws ResponseError If the response body is not valid multipart form-data.
56
+ */
57
+ export declare function parseResponseFormData(response: Response, caller?: AnyCaller): Promise<FormData>;
39
58
  /**
40
59
  * Get an HTTP `Response` for an unknown value.
41
60
  *
@@ -57,20 +76,16 @@ export declare function getResponse(value: unknown): Response;
57
76
  * @param debug If `true` include the error message in the response (for debugging), or `false` to return generic error codes (for security).
58
77
  */
59
78
  export declare function getErrorResponse(reason: unknown, debug?: boolean): Response;
60
- /** The set of supported HTTP methods that do not send a request body. */
61
- export declare const HTTP_HEAD_METHODS: readonly ["HEAD", "GET"];
62
- /** The set of supported HTTP methods that may send a request body. */
63
- export declare const HTTP_BODY_METHODS: readonly ["POST", "PUT", "PATCH", "DELETE"];
64
- /** The full set of supported HTTP methods. */
65
- export declare const HTTP_METHODS: readonly ["HEAD", "GET", "POST", "PUT", "PATCH", "DELETE"];
66
79
  /** HTTP request methods that have no body. */
67
- export type RequestHeadMethod = (typeof HTTP_HEAD_METHODS)[number];
80
+ export type RequestHeadMethod = "HEAD" | "GET";
68
81
  /** HTTP request methods that have a body. */
69
- export type RequestBodyMethod = (typeof HTTP_BODY_METHODS)[number];
82
+ export type RequestBodyMethod = "POST" | "PUT" | "PATCH" | "DELETE";
70
83
  /** HTTP request methods. */
71
- export type RequestMethod = (typeof HTTP_METHODS)[number];
72
- /** Check whether an arbitrary method string is one of Shelving's supported request methods. */
84
+ export type RequestMethod = RequestHeadMethod | RequestBodyMethod;
85
+ /** Check whether an HTTP Request method string is a supported request methods. */
73
86
  export declare function isRequestMethod(method: string): method is RequestMethod;
87
+ /** Check whether an HTTP Request method string is a supported request method that never sends a body. */
88
+ export declare function isRequestHeadMethod(method: string): method is RequestHeadMethod;
74
89
  /** Params in requests are a dictionary of strings. */
75
90
  export type RequestParams = ImmutableDictionary<string>;
76
91
  /** Configurable options for endpoint requests. */
@@ -83,8 +98,77 @@ export type RequestOptions = Pick<RequestInit, "cache" | "credentials" | "header
83
98
  */
84
99
  export declare function mergeRequestOptions({ headers: aHeaders, signal: aSignal, ...a }?: RequestOptions, { headers: bHeaders, signal: bSignal, ...b }?: RequestOptions): RequestOptions;
85
100
  /**
86
- * Create a `Request` instance with a valid content type based on the body.
101
+ * Create a body-less `Request`.
102
+ * - `HEAD` and `GET` requests never send a body.
103
+ *
104
+ * @param method The HTTP method.
105
+ * @param url The target URL.
106
+ * @param params `?query` params to encode into the URL.
107
+ * @param options Additional request options.
108
+ * @returns A `Request` with no body content.
109
+ *
110
+ * @example getHeadRequest("POST", "https://api.example.com/items", { name: "abc" })
111
+ */
112
+ export declare function getHeadRequest(method: RequestHeadMethod, url: PossibleURL, params: Nullish<PossibleURIParams>, options?: RequestOptions, caller?: AnyCaller): Request;
113
+ /**
114
+ * Create a plain-text `Request`.
115
+ *
116
+ * - `HEAD` and `GET` requests never send a body.
87
117
  *
118
+ * @param method The HTTP method.
119
+ * @param url The target URL.
120
+ * @param body The plain-text request body.
121
+ * @param options Additional request options.
122
+ * @returns A `Request` with `text/plain` content type.
123
+ *
124
+ * @example getTextRequest("POST", "https://api.example.com/items", "hello")
125
+ */
126
+ export declare function getTextRequest(method: RequestMethod, url: PossibleURL, body: string, options?: RequestOptions, caller?: AnyCaller): Request;
127
+ /**
128
+ * Create a JSON `Request`.
129
+ * - `HEAD` and `GET` requests never send a body.
130
+ * - If the JSON body is a data object for `HEAD` or `GET`, it is appended as `?query` params instead.
131
+ *
132
+ * @param method The HTTP method.
133
+ * @param url The target URL.
134
+ * @param body The value to JSON-encode.
135
+ * @param options Additional request options.
136
+ * @returns A `Request` with `application/json` content type.
137
+ *
138
+ * @example getJSONRequest("POST", "https://api.example.com/items", { name: "abc" })
139
+ */
140
+ export declare function getJSONRequest(method: RequestBodyMethod, url: PossibleURL, body: unknown, options?: RequestOptions, caller?: AnyCaller): Request;
141
+ /**
142
+ * Create a multipart form-data `Request`.
143
+ * - `HEAD` and `GET` requests never send a body.
144
+ *
145
+ * @param method The HTTP method.
146
+ * @param url The target URL.
147
+ * @param body The `FormData` payload.
148
+ * @param options Additional request options.
149
+ * @returns A `Request` with a multipart body.
150
+ *
151
+ * @example getFormDataRequest("POST", "https://api.example.com/upload", new FormData())
152
+ */
153
+ export declare function getFormDataRequest(method: RequestBodyMethod, url: PossibleURL, body: FormData, options?: RequestOptions, caller?: AnyCaller): Request;
154
+ /**
155
+ * Create an XML `Request`.
156
+ * - `HEAD` and `GET` requests never send a body.
157
+ * - For `HEAD` and `GET`, the data object is appended as `?query` params instead.
158
+ *
159
+ * @param method The HTTP method.
160
+ * @param url The target URL.
161
+ * @param data The data object to serialize as XML.
162
+ * @param options Additional request options.
163
+ * @returns A `Request` with `application/xml` content type.
164
+ *
165
+ * @throws {RequiredError} If the XML data contains invalid element names or values.
166
+ *
167
+ * @example getXMLRequest("POST", "https://api.example.com/items", { item: { name: "abc" } })
168
+ */
169
+ export declare function getXMLRequest(method: RequestBodyMethod, url: PossibleURL, data: Data, options?: RequestOptions, caller?: AnyCaller): Request;
170
+ /**
171
+ * Create a `Request` instance with a valid content type based on the body.
88
172
  * - `undefined` or `null` are sent with no body.
89
173
  * - `FormData` is sent with `multipart/formdata`
90
174
  * - `string` is sent with `text/plain` header.
@@ -98,4 +182,4 @@ export declare function mergeRequestOptions({ headers: aHeaders, signal: aSignal
98
182
  */
99
183
  export declare function getRequest(method: RequestMethod, url: PossibleURL, payload: unknown, options?: RequestOptions, caller?: AnyCaller): Request;
100
184
  /** Assert that the payload for a HEAD or GET method is a data object, null, or undefined. */
101
- export declare function assertHeadMethodPayload(payload: unknown, method: RequestHeadMethod, caller?: AnyCaller): asserts payload is Nullish<Data>;
185
+ export declare function assertRequestHeadPayload(payload: unknown, method: RequestHeadMethod, caller?: AnyCaller): asserts payload is Nullish<Data>;
package/util/http.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { RequestError } from "../error/RequestError.js";
2
2
  import { RequiredError } from "../error/RequiredError.js";
3
3
  import { ResponseError } from "../error/ResponseError.js";
4
- import { isArrayItem } from "./array.js";
5
4
  import { isData } from "./data.js";
6
5
  import { isError } from "./error.js";
7
6
  import { isNullish } from "./null.js";
8
7
  import { withURIParams } from "./uri.js";
9
8
  import { requireURL } from "./url.js";
10
- export async function _getMessageJSON(message, MessageError, caller) {
9
+ import { getXML } from "./xml.js";
10
+ /** Get parsed `JSON` from a `Request` or `Response`. */
11
+ async function _parseMessageJSON(message, MessageError, caller) {
11
12
  const trimmed = (await message.text()).trim();
12
13
  if (!trimmed.length)
13
14
  return undefined;
@@ -18,7 +19,8 @@ export async function _getMessageJSON(message, MessageError, caller) {
18
19
  throw new MessageError("Body must be valid JSON", { received: trimmed, cause, caller });
19
20
  }
20
21
  }
21
- export async function _getMessageFormData(message, MessageError, caller) {
22
+ /** Get parsed `FormData` from a `Request` or `Response`. */
23
+ async function _parseMessageFormData(message, MessageError, caller) {
22
24
  try {
23
25
  return await message.formData();
24
26
  }
@@ -26,18 +28,19 @@ export async function _getMessageFormData(message, MessageError, caller) {
26
28
  throw new MessageError(`Body must be valid form multipart data`, { cause, caller });
27
29
  }
28
30
  }
29
- export function _getMessageContent(message, MessageError, caller) {
31
+ /** Get parsed body from a `Request` or `Response`. */
32
+ function _parseMessageBody(message, MessageError, caller) {
30
33
  const type = message.headers.get("Content-Type");
31
- if (!type || type?.startsWith("text/"))
34
+ if (type?.startsWith("text/"))
32
35
  return message.text();
33
36
  if (type?.startsWith("application/json"))
34
- return _getMessageJSON(message, MessageError, caller);
37
+ return _parseMessageJSON(message, MessageError, caller);
35
38
  if (type?.startsWith("multipart/form-data"))
36
- return _getMessageFormData(message, MessageError, caller);
37
- return Promise.resolve();
39
+ return _parseMessageFormData(message, MessageError, caller);
40
+ return Promise.resolve(undefined);
38
41
  }
39
42
  /**
40
- * Get the body content of an HTTP `Request` based on its content type, or throw `RequestError` if the content could not be parsed.
43
+ * Parse the body content of an HTTP `Request` based on its content type, or throw `RequestError` if the content could not be parsed.
41
44
  *
42
45
  * @returns undefined If the request method is `GET` or `HEAD` (these request methods have no body).
43
46
  * @returns unknown If content type is `application/json` and has valid JSON (including `undefined` if the content is empty).
@@ -46,29 +49,52 @@ export function _getMessageContent(message, MessageError, caller) {
46
49
  *
47
50
  * @throws RequestError if the content is not `text/plain`, or `application/json` with valid JSON.
48
51
  */
49
- export function getRequestContent(request, caller = getRequestContent) {
50
- const { method } = request;
51
- // The HTTP/1.1 RFC 7231 does not forbid sending a body in GET or HEAD requests, but it is uncommon and not recommended because many servers, proxies, and caches may ignore or mishandle it.
52
- if (method === "GET" || method === "HEAD")
53
- return Promise.resolve(undefined);
54
- return _getMessageContent(request, RequestError, caller);
52
+ export function parseRequestBody(request, caller = parseRequestBody) {
53
+ return _parseMessageBody(request, RequestError, caller);
55
54
  }
56
55
  /**
57
- * Get the body content of an HTTP `Response` based on its content type, or throw `ResponseError` if the content could not be parsed.
56
+ * Parse JSON from an HTTP `Request`, or return `undefined` when the request has no body.
57
+ *
58
+ * @throws RequestError If the request body is not valid JSON.
59
+ */
60
+ export function parseRequestJSON(request, caller = parseRequestJSON) {
61
+ return _parseMessageJSON(request, RequestError, caller);
62
+ }
63
+ /**
64
+ * Parse `FormData` from an HTTP `Request`, or return `undefined` when the request has no body.
65
+ *
66
+ * @throws RequestError If the request body is not valid multipart form-data.
67
+ */
68
+ export function parseRequestFormData(request, caller = parseRequestFormData) {
69
+ return _parseMessageFormData(request, RequestError, caller);
70
+ }
71
+ /**
72
+ * Parse the body content of an HTTP `Response` based on its content type, or throw `ResponseError` if the content could not be parsed.
58
73
  *
59
- * @returns undefined If the request status is `204 No Content` (this response has no body).
60
74
  * @returns unknown If content type is `application/json` and has valid JSON (including `undefined` if the content is empty).
61
75
  * @returns unknown If content type is `multipart/form-data` then convert it to a simple `Data` object.
62
76
  * @returns string If content type is `text/plain` or anything else (including `""` empty string if it's empty).
63
77
  *
64
- * @throws RequestError if the content is not `text/plain` or `application/json` with valid JSON.
78
+ * @throws ResponseError if the content is not `text/plain` or `application/json` with valid JSON.
79
+ */
80
+ export function parseResponseBody(response, caller = parseResponseBody) {
81
+ return _parseMessageBody(response, ResponseError, caller);
82
+ }
83
+ /**
84
+ * Parse JSON from an HTTP `Response`, or return `undefined` when the response has no body.
85
+ *
86
+ * @throws ResponseError If the response body is not valid JSON.
65
87
  */
66
- export function getResponseContent(response, caller = getResponseContent) {
67
- const { status } = response;
68
- // RFC 7230 Section 3.3.3: A server MUST NOT send a Content-Length header field in any response with a status code of 1xx (Informational), 204 (No Content), or 304 (Not Modified).
69
- if ((status >= 100 && status < 200) || status === 204 || status === 304)
70
- return Promise.resolve(undefined);
71
- return _getMessageContent(response, ResponseError, caller);
88
+ export function parseResponseJSON(response, caller = parseResponseJSON) {
89
+ return _parseMessageJSON(response, ResponseError, caller);
90
+ }
91
+ /**
92
+ * Parse `FormData` from an HTTP `Response`, or return `undefined` when the response has no body.
93
+ *
94
+ * @throws ResponseError If the response body is not valid multipart form-data.
95
+ */
96
+ export function parseResponseFormData(response, caller = parseResponseFormData) {
97
+ return _parseMessageFormData(response, ResponseError, caller);
72
98
  }
73
99
  /**
74
100
  * Get an HTTP `Response` for an unknown value.
@@ -117,20 +143,18 @@ export function getErrorResponse(reason, debug = false) {
117
143
  // Otherwise return a generic error message with no details.
118
144
  return new Response(undefined, { status });
119
145
  }
120
- /** The set of supported HTTP methods that do not send a request body. */
121
- export const HTTP_HEAD_METHODS = ["HEAD", "GET"];
122
- /** The set of supported HTTP methods that may send a request body. */
123
- export const HTTP_BODY_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
124
- /** The full set of supported HTTP methods. */
125
- export const HTTP_METHODS = [...HTTP_HEAD_METHODS, ...HTTP_BODY_METHODS];
126
- /** Check whether an arbitrary method string is one of Shelving's supported request methods. */
146
+ // Method arrays.
147
+ const _REQUEST_HEAD_METHODS = ["HEAD", "GET"];
148
+ const _REQUEST_BODY_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
149
+ const _REQUEST_METHODS = [..._REQUEST_HEAD_METHODS, ..._REQUEST_BODY_METHODS];
150
+ /** Check whether an HTTP Request method string is a supported request methods. */
127
151
  export function isRequestMethod(method) {
128
- return HTTP_METHODS.includes(method);
152
+ return _REQUEST_METHODS.includes(method);
153
+ }
154
+ /** Check whether an HTTP Request method string is a supported request method that never sends a body. */
155
+ export function isRequestHeadMethod(method) {
156
+ return _REQUEST_HEAD_METHODS.includes(method);
129
157
  }
130
- /** Options for a plain text request. */
131
- const REQUEST_TEXT_OPTIONS = { headers: { "Content-Type": "text/plain" } };
132
- /** Options for a JSON request. */
133
- const REQUEST_JSON_OPTIONS = { headers: { "Content-Type": "application/json" } };
134
158
  /**
135
159
  * Merge provider-level and call-level request options.
136
160
  * - Scalar options from `b` override `a`.
@@ -143,8 +167,98 @@ export function mergeRequestOptions({ headers: aHeaders, signal: aSignal, ...a }
143
167
  return { ...a, ...b, signal, headers };
144
168
  }
145
169
  /**
146
- * Create a `Request` instance with a valid content type based on the body.
170
+ * Create a body-less `Request`.
171
+ * - `HEAD` and `GET` requests never send a body.
172
+ *
173
+ * @param method The HTTP method.
174
+ * @param url The target URL.
175
+ * @param params `?query` params to encode into the URL.
176
+ * @param options Additional request options.
177
+ * @returns A `Request` with no body content.
178
+ *
179
+ * @example getHeadRequest("POST", "https://api.example.com/items", { name: "abc" })
180
+ */
181
+ export function getHeadRequest(method, url, params, options = {}, caller = getHeadRequest) {
182
+ return new Request(withURIParams(requireURL(url, undefined, caller), params), { ...options, method, body: null });
183
+ }
184
+ /**
185
+ * Create a plain-text `Request`.
147
186
  *
187
+ * - `HEAD` and `GET` requests never send a body.
188
+ *
189
+ * @param method The HTTP method.
190
+ * @param url The target URL.
191
+ * @param body The plain-text request body.
192
+ * @param options Additional request options.
193
+ * @returns A `Request` with `text/plain` content type.
194
+ *
195
+ * @example getTextRequest("POST", "https://api.example.com/items", "hello")
196
+ */
197
+ export function getTextRequest(method, url, body, options = {}, caller = getTextRequest) {
198
+ return new Request(requireURL(url, undefined, caller), { ...mergeRequestOptions(_REQUEST_TEXT_OPTIONS, options), method, body });
199
+ }
200
+ const _REQUEST_TEXT_OPTIONS = { headers: { "Content-Type": "text/plain" } };
201
+ /**
202
+ * Create a JSON `Request`.
203
+ * - `HEAD` and `GET` requests never send a body.
204
+ * - If the JSON body is a data object for `HEAD` or `GET`, it is appended as `?query` params instead.
205
+ *
206
+ * @param method The HTTP method.
207
+ * @param url The target URL.
208
+ * @param body The value to JSON-encode.
209
+ * @param options Additional request options.
210
+ * @returns A `Request` with `application/json` content type.
211
+ *
212
+ * @example getJSONRequest("POST", "https://api.example.com/items", { name: "abc" })
213
+ */
214
+ export function getJSONRequest(method, url, body, options = {}, caller = getJSONRequest) {
215
+ return new Request(requireURL(url, undefined, caller), {
216
+ ...mergeRequestOptions(_REQUEST_JSON_OPTIONS, options),
217
+ method,
218
+ body: JSON.stringify(body),
219
+ });
220
+ }
221
+ const _REQUEST_JSON_OPTIONS = { headers: { "Content-Type": "application/json" } };
222
+ /**
223
+ * Create a multipart form-data `Request`.
224
+ * - `HEAD` and `GET` requests never send a body.
225
+ *
226
+ * @param method The HTTP method.
227
+ * @param url The target URL.
228
+ * @param body The `FormData` payload.
229
+ * @param options Additional request options.
230
+ * @returns A `Request` with a multipart body.
231
+ *
232
+ * @example getFormDataRequest("POST", "https://api.example.com/upload", new FormData())
233
+ */
234
+ export function getFormDataRequest(method, url, body, options = {}, caller = getFormDataRequest) {
235
+ return new Request(requireURL(url, undefined, caller), { ...options, method, body });
236
+ }
237
+ /**
238
+ * Create an XML `Request`.
239
+ * - `HEAD` and `GET` requests never send a body.
240
+ * - For `HEAD` and `GET`, the data object is appended as `?query` params instead.
241
+ *
242
+ * @param method The HTTP method.
243
+ * @param url The target URL.
244
+ * @param data The data object to serialize as XML.
245
+ * @param options Additional request options.
246
+ * @returns A `Request` with `application/xml` content type.
247
+ *
248
+ * @throws {RequiredError} If the XML data contains invalid element names or values.
249
+ *
250
+ * @example getXMLRequest("POST", "https://api.example.com/items", { item: { name: "abc" } })
251
+ */
252
+ export function getXMLRequest(method, url, data, options = {}, caller = getXMLRequest) {
253
+ return new Request(requireURL(url, undefined, caller), {
254
+ ...mergeRequestOptions(_REQUEST_XML_OPTIONS, options),
255
+ method,
256
+ body: `<?xml version="1.0" encoding="UTF-8"?>${getXML(data, caller)}`,
257
+ });
258
+ }
259
+ const _REQUEST_XML_OPTIONS = { headers: { "Content-Type": "application/xml; charset=UTF-8" } };
260
+ /**
261
+ * Create a `Request` instance with a valid content type based on the body.
148
262
  * - `undefined` or `null` are sent with no body.
149
263
  * - `FormData` is sent with `multipart/formdata`
150
264
  * - `string` is sent with `text/plain` header.
@@ -162,21 +276,21 @@ export function getRequest(method, url, payload, options = {}, caller = getReque
162
276
  if (isNullish(payload))
163
277
  return new Request(url, { ...options, method, body: null });
164
278
  // HEAD or GET have no body (but payload can only be data object).
165
- if (isArrayItem(HTTP_HEAD_METHODS, method)) {
166
- assertHeadMethodPayload(payload, method, caller);
279
+ if (isRequestHeadMethod(method)) {
280
+ assertRequestHeadPayload(payload, method, caller);
167
281
  return new Request(withURIParams(url, payload), { ...options, method, body: null });
168
282
  }
169
283
  // `FormData` instances in body pass through unaltered and will set their own `Content-Type` with complex boundary information
170
284
  if (payload instanceof FormData)
171
- return new Request(url, { ...options, method, body: payload });
285
+ return getFormDataRequest(method, url, payload, options, caller);
172
286
  // Strings are sent as plain text.
173
287
  if (typeof payload === "string")
174
- return new Request(url, { ...mergeRequestOptions(REQUEST_TEXT_OPTIONS, options), method, body: payload });
288
+ return getTextRequest(method, url, payload, options, caller);
175
289
  // JSON is the default.
176
- return new Request(url, { ...mergeRequestOptions(REQUEST_JSON_OPTIONS, options), method, body: JSON.stringify(payload) });
290
+ return getJSONRequest(method, url, payload, options, caller);
177
291
  }
178
292
  /** Assert that the payload for a HEAD or GET method is a data object, null, or undefined. */
179
- export function assertHeadMethodPayload(payload, method, caller = assertHeadMethodPayload) {
293
+ export function assertRequestHeadPayload(payload, method, caller = assertRequestHeadPayload) {
180
294
  if (!isData(payload) && !isNullish(payload))
181
295
  throw new RequiredError(`Payload for ${method} request must be data object, null, or undefined`, { received: payload, caller });
182
296
  }
package/util/index.d.ts CHANGED
@@ -60,3 +60,4 @@ export * from "./uri.js";
60
60
  export * from "./url.js";
61
61
  export * from "./uuid.js";
62
62
  export * from "./validate.js";
63
+ export * from "./xml.js";
package/util/index.js CHANGED
@@ -60,3 +60,4 @@ export * from "./uri.js";
60
60
  export * from "./url.js";
61
61
  export * from "./uuid.js";
62
62
  export * from "./validate.js";
63
+ export * from "./xml.js";
package/util/object.js CHANGED
@@ -58,6 +58,8 @@ export function withProps(input, props) {
58
58
  return input;
59
59
  }
60
60
  export function omitProps(input, ...keys) {
61
+ if (!keys.length)
62
+ return input;
61
63
  for (const key of keys)
62
64
  if (key in input)
63
65
  return Object.fromEntries(Object.entries(input).filter(_hasntKey, keys));
package/util/xml.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { type Data } from "./data.js";
2
+ import type { AnyCaller } from "./function.js";
3
+ /**
4
+ * Escape a string for safe inclusion in XML text content or attribute values.
5
+ *
6
+ * @param value The raw string value.
7
+ * @returns The escaped XML-safe string.
8
+ *
9
+ * @example
10
+ * escapeXML(`Tom & "Jerry"`)
11
+ */
12
+ export declare function escapeXML(value: string): string;
13
+ /**
14
+ * Build an XML string from a data object.
15
+ *
16
+ * - Only data objects can be converted directly because XML requires named root and child elements.
17
+ * - `undefined` values are omitted from the output.
18
+ * - Nested data objects become nested XML elements.
19
+ *
20
+ * @param data The data object to serialize.
21
+ * @param caller The calling function for error context.
22
+ * @returns The serialized XML string.
23
+ *
24
+ * @throws {RequiredError} If a key is not a valid XML element name.
25
+ * @throws {RequiredError} If a value cannot be converted to XML.
26
+ *
27
+ * @example
28
+ * getXML({ user: { name: "Alice", age: 30 } })
29
+ */
30
+ export declare function getXML(data: Data, caller?: AnyCaller): string;
package/util/xml.js ADDED
@@ -0,0 +1,54 @@
1
+ import { RequiredError } from "../error/RequiredError.js";
2
+ import { getDataProps, isData } from "./data.js";
3
+ import { requireString } from "./string.js";
4
+ import { isDefined } from "./undefined.js";
5
+ /**
6
+ * Escape a string for safe inclusion in XML text content or attribute values.
7
+ *
8
+ * @param value The raw string value.
9
+ * @returns The escaped XML-safe string.
10
+ *
11
+ * @example
12
+ * escapeXML(`Tom & "Jerry"`)
13
+ */
14
+ export function escapeXML(value) {
15
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
16
+ }
17
+ /**
18
+ * Build an XML string from a data object.
19
+ *
20
+ * - Only data objects can be converted directly because XML requires named root and child elements.
21
+ * - `undefined` values are omitted from the output.
22
+ * - Nested data objects become nested XML elements.
23
+ *
24
+ * @param data The data object to serialize.
25
+ * @param caller The calling function for error context.
26
+ * @returns The serialized XML string.
27
+ *
28
+ * @throws {RequiredError} If a key is not a valid XML element name.
29
+ * @throws {RequiredError} If a value cannot be converted to XML.
30
+ *
31
+ * @example
32
+ * getXML({ user: { name: "Alice", age: 30 } })
33
+ */
34
+ export function getXML(data, caller = getXML) {
35
+ return Array.from(_yieldXML(data, caller)).join("");
36
+ }
37
+ function* _yieldXML(data, caller) {
38
+ for (const [key, value] of getDataProps(data)) {
39
+ if (!R_XML_KEY.test(key))
40
+ throw new RequiredError("Invalid XML key", { received: key, caller });
41
+ if (isDefined(value))
42
+ yield `<${key}>${_getXMLValue(value, caller)}</${key}>`;
43
+ }
44
+ }
45
+ function _getXMLValue(value, caller) {
46
+ if (typeof value === "string")
47
+ return escapeXML(value);
48
+ if (typeof value === "number" || typeof value === "boolean")
49
+ return requireString(value, undefined, undefined, caller);
50
+ if (isData(value))
51
+ return getXML(value, caller);
52
+ throw new RequiredError("Value cannot be converted to XML", { received: value, caller });
53
+ }
54
+ const R_XML_KEY = /^[a-z][a-z0-9]*$/i;