@warp-drive-mirror/utilities 5.8.0-beta.0 → 5.8.0-beta.2

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.
Files changed (74) hide show
  1. package/README.md +16 -25
  2. package/declarations/-private/active-record/find-record.d.ts +5 -6
  3. package/declarations/-private/active-record/query.d.ts +2 -2
  4. package/declarations/-private/active-record/save-record.d.ts +2 -2
  5. package/declarations/-private/handlers/meta-doc.d.ts +1 -1
  6. package/declarations/-private/json-api/find-record.d.ts +37 -18
  7. package/declarations/-private/json-api/query.d.ts +3 -6
  8. package/declarations/-private/json-api/save-record.d.ts +45 -10
  9. package/declarations/-private/json-api/serialize.d.ts +15 -1
  10. package/declarations/-private/rest/find-record.d.ts +5 -8
  11. package/declarations/-private/rest/query.d.ts +2 -2
  12. package/declarations/-private/rest/save-record.d.ts +2 -2
  13. package/dist/active-record.js +2 -0
  14. package/dist/handlers.js +3 -3
  15. package/dist/json-api.js +94 -26
  16. package/dist/rest.js +2 -2
  17. package/dist/string.js +428 -1
  18. package/dist/unpkg/dev/-private.js +7 -0
  19. package/dist/unpkg/dev/active-record.js +394 -0
  20. package/dist/unpkg/dev/builder-utils-Donkk-BZ.js +22 -0
  21. package/dist/unpkg/dev/derivations.js +30 -0
  22. package/dist/unpkg/dev/handlers.js +316 -0
  23. package/dist/unpkg/dev/index.js +362 -0
  24. package/dist/unpkg/dev/inflect-BEv8WqY1.js +343 -0
  25. package/dist/unpkg/dev/json-api.js +740 -0
  26. package/dist/unpkg/dev/rest.js +392 -0
  27. package/dist/unpkg/dev/string.js +1 -0
  28. package/dist/unpkg/dev-deprecated/-private.js +7 -0
  29. package/dist/unpkg/dev-deprecated/active-record.js +394 -0
  30. package/dist/unpkg/dev-deprecated/builder-utils-Donkk-BZ.js +22 -0
  31. package/dist/unpkg/dev-deprecated/derivations.js +30 -0
  32. package/dist/unpkg/dev-deprecated/handlers.js +316 -0
  33. package/dist/unpkg/dev-deprecated/index.js +362 -0
  34. package/dist/unpkg/dev-deprecated/inflect-BEv8WqY1.js +343 -0
  35. package/dist/unpkg/dev-deprecated/json-api.js +740 -0
  36. package/dist/unpkg/dev-deprecated/rest.js +392 -0
  37. package/dist/unpkg/dev-deprecated/string.js +1 -0
  38. package/dist/unpkg/prod/-private.js +7 -0
  39. package/dist/unpkg/prod/active-record.js +366 -0
  40. package/dist/unpkg/prod/builder-utils-Donkk-BZ.js +22 -0
  41. package/dist/unpkg/prod/derivations.js +30 -0
  42. package/dist/unpkg/prod/handlers.js +305 -0
  43. package/dist/unpkg/prod/index.js +239 -0
  44. package/dist/unpkg/prod/inflect-Dh9dyEYx.js +333 -0
  45. package/dist/unpkg/prod/json-api.js +702 -0
  46. package/dist/unpkg/prod/rest.js +364 -0
  47. package/dist/unpkg/prod/string.js +1 -0
  48. package/dist/unpkg/prod-deprecated/-private.js +7 -0
  49. package/dist/unpkg/prod-deprecated/active-record.js +366 -0
  50. package/dist/unpkg/prod-deprecated/builder-utils-Donkk-BZ.js +22 -0
  51. package/dist/unpkg/prod-deprecated/derivations.js +30 -0
  52. package/dist/unpkg/prod-deprecated/handlers.js +305 -0
  53. package/dist/unpkg/prod-deprecated/index.js +239 -0
  54. package/dist/unpkg/prod-deprecated/inflect-Dh9dyEYx.js +333 -0
  55. package/dist/unpkg/prod-deprecated/json-api.js +702 -0
  56. package/dist/unpkg/prod-deprecated/rest.js +364 -0
  57. package/dist/unpkg/prod-deprecated/string.js +1 -0
  58. package/logos/README.md +2 -2
  59. package/logos/logo-yellow-slab.svg +1 -0
  60. package/logos/word-mark-black.svg +1 -0
  61. package/logos/word-mark-white.svg +1 -0
  62. package/package.json +13 -5
  63. package/logos/NCC-1701-a-blue.svg +0 -4
  64. package/logos/NCC-1701-a-gold.svg +0 -4
  65. package/logos/NCC-1701-a-gold_100.svg +0 -1
  66. package/logos/NCC-1701-a-gold_base-64.txt +0 -1
  67. package/logos/NCC-1701-a.svg +0 -4
  68. package/logos/docs-badge.svg +0 -2
  69. package/logos/ember-data-logo-dark.svg +0 -12
  70. package/logos/ember-data-logo-light.svg +0 -12
  71. package/logos/social1.png +0 -0
  72. package/logos/social2.png +0 -0
  73. package/logos/warp-drive-logo-dark.svg +0 -4
  74. package/logos/warp-drive-logo-gold.svg +0 -4
@@ -0,0 +1,316 @@
1
+ function isCompressibleMethod(method) {
2
+ return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE';
3
+ }
4
+
5
+ /**
6
+ * Whether the browser supports `ReadableStream` as a request body
7
+ * in a `POST` request.
8
+ *
9
+ * @group Constants
10
+ */
11
+ const SupportsRequestStreams = (() => {
12
+ let duplexAccessed = false;
13
+ const hasContentType = new Request('', {
14
+ body: new ReadableStream(),
15
+ method: 'POST',
16
+ // @ts-expect-error untyped
17
+ get duplex() {
18
+ duplexAccessed = true;
19
+ return 'half';
20
+ }
21
+ }).headers.has('Content-Type');
22
+ return duplexAccessed && !hasContentType;
23
+ })();
24
+
25
+ /**
26
+ * Options for configuring the AutoCompress handler.
27
+ *
28
+ */
29
+
30
+ const DEFAULT_CONSTRAINTS = {
31
+ Blob: 1000,
32
+ ArrayBuffer: 1000,
33
+ TypedArray: 1000,
34
+ DataView: 1000,
35
+ String: 1000
36
+ };
37
+ const TypedArray = Object.getPrototypeOf(Uint8Array);
38
+
39
+ /**
40
+ * A request handler that automatically compresses the request body
41
+ * if the request body is a string, array buffer, blob, or form data.
42
+ *
43
+ * This uses the [CompressionStream API](https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream)
44
+ *
45
+ * The compression format as well as the kinds of data to compress can be
46
+ * configured using the `format` and `constraints` options.
47
+ *
48
+ * ```diff
49
+ * +import { AutoCompress } from '@ember-data-mirror/request-utils/handlers';
50
+ * import Fetch from '@ember-data-mirror/request/fetch';
51
+ * import RequestManager from '@ember-data-mirror/request';
52
+ * import Store from '@ember-data-mirror/store';
53
+ *
54
+ * class AppStore extends Store {
55
+ * requestManager = new RequestManager()
56
+ * .use([
57
+ * + new AutoCompress(),
58
+ * Fetch
59
+ * ]);
60
+ * }
61
+ * ```
62
+ *
63
+ * @group Handlers
64
+ * @public
65
+ * @since 5.5.0
66
+ */
67
+ class AutoCompress {
68
+ constructor(options = {}) {
69
+ const opts = {
70
+ format: options.format ?? 'gzip',
71
+ constraints: Object.assign({}, DEFAULT_CONSTRAINTS, options.constraints),
72
+ allowStreaming: options.allowStreaming ?? false,
73
+ forceStreaming: options.forceStreaming ?? false
74
+ };
75
+ this.options = opts;
76
+ }
77
+ request({
78
+ request
79
+ }, next) {
80
+ const {
81
+ constraints
82
+ } = this.options;
83
+ const {
84
+ body
85
+ } = request;
86
+ const shouldCompress = !!body && isCompressibleMethod(request.method) && request.options?.compress !== false && (
87
+ // prettier-ignore
88
+ request.options?.compress ? true : typeof body === 'string' || body instanceof String ? canCompress('String', constraints, body.length) : body instanceof Blob ? canCompress('Blob', constraints, body.size) : body instanceof ArrayBuffer ? canCompress('ArrayBuffer', constraints, body.byteLength) : body instanceof DataView ? canCompress('DataView', constraints, body.byteLength) : body instanceof TypedArray ? canCompress('TypedArray', constraints, body.byteLength) : false);
89
+ if (!shouldCompress) return next(request);
90
+
91
+ // A convenient way to convert all of the supported body types to a readable
92
+ // stream is to use a `Response` object body
93
+ const response = new Response(request.body);
94
+ const stream = response.body?.pipeThrough(new CompressionStream(this.options.format));
95
+ const headers = new Headers(request.headers);
96
+ headers.set('Content-Encoding', encodingForFormat(this.options.format));
97
+
98
+ //
99
+ // For browsers that support it, `fetch` can receive a `ReadableStream` as
100
+ // the body, so all we need to do is to create a new `ReadableStream` and
101
+ // compress it on the fly
102
+ //
103
+ const forceStreaming = request.options?.forceStreaming ?? this.options.forceStreaming;
104
+ const allowStreaming = request.options?.allowStreaming ?? this.options.allowStreaming;
105
+ if (forceStreaming || SupportsRequestStreams && allowStreaming) {
106
+ const req = Object.assign({}, request, {
107
+ body: stream,
108
+ headers
109
+ });
110
+ if (SupportsRequestStreams) {
111
+ // @ts-expect-error untyped
112
+ req.duplex = 'half';
113
+ }
114
+ return next(req);
115
+
116
+ //
117
+ // For non-chromium browsers, we have to "pull" the stream to get the final
118
+ // bytes and supply the final byte array as the new request body.
119
+ //
120
+ }
121
+
122
+ // we need to pull the stream to get the final bytes
123
+ const resp = new Response(stream);
124
+ return resp.blob().then(blob => {
125
+ const req = Object.assign({}, request, {
126
+ body: blob,
127
+ headers
128
+ });
129
+ return next(req);
130
+ });
131
+ }
132
+ }
133
+ function canCompress(type, constraints, size) {
134
+ // if we have a value of 0, we can compress anything
135
+ if (constraints[type] === 0) return true;
136
+ if (constraints[type] === -1) return false;
137
+ return size >= constraints[type];
138
+ }
139
+ function encodingForFormat(format) {
140
+ switch (format) {
141
+ case 'gzip':
142
+ case 'deflate':
143
+ case 'deflate-raw':
144
+ return format;
145
+ default:
146
+ (test => {
147
+ {
148
+ throw new Error(`Unsupported compression format: ${format}`);
149
+ }
150
+ })();
151
+ // @ts-expect-error - unreachable code is reachable in production
152
+ return format;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * If CheckFn returns true, the wrapped handler will be used.
158
+ * If CheckFn returns false, the wrapped handler will be skipped.
159
+ */
160
+
161
+ /**
162
+ *
163
+ * @group Handlers
164
+ * @public
165
+ */
166
+ class Gate {
167
+ constructor(handler, checkFn) {
168
+ this.handler = handler;
169
+ this.checkFn = checkFn;
170
+ }
171
+ request(context, next) {
172
+ if (this.checkFn(context)) {
173
+ return this.handler.request(context, next);
174
+ }
175
+ return next(context.request);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * MetaDocHandler processes requests that are marked as meta requests.
181
+ *
182
+ * It treats the response body as "entirely meta" transforming
183
+ *
184
+ * ```ts
185
+ * {
186
+ * some: "key",
187
+ * another: "thing"
188
+ * }
189
+ * ```
190
+ *
191
+ * into
192
+ *
193
+ * ```ts
194
+ * {
195
+ * meta: {
196
+ * some: "key",
197
+ * another: "thing"
198
+ * }
199
+ * }
200
+ * ```
201
+ *
202
+ * To activate this handler, a request should specify
203
+ *
204
+ * ```ts
205
+ * options.isMetaRequest = true
206
+ * ```
207
+ *
208
+ * For instance
209
+ *
210
+ * ```ts
211
+ * store.request({
212
+ * url: '/example',
213
+ * options: {
214
+ * isMetaRequest: true
215
+ * }
216
+ * });
217
+ * ```
218
+ *
219
+ * Errors are not processed by this handler, so if the request fails and the error response
220
+ * is not in {json:api} format additional processing may be needed.
221
+ *
222
+ * @group Handlers
223
+ */
224
+ const MetaDocHandler = {
225
+ request(context, next) {
226
+ if (!context.request.options?.isMetaRequest) {
227
+ return next(context.request);
228
+ }
229
+ return next(context.request).then(response => {
230
+ return processResponse(response);
231
+ });
232
+ }
233
+ };
234
+ function processResponse(response) {
235
+ return {
236
+ meta: response.content
237
+ };
238
+ }
239
+ if (typeof FastBoot === 'undefined') {
240
+ globalThis.addEventListener('beforeunload', function () {
241
+ sessionStorage.setItem('tab-closed', 'true');
242
+ });
243
+ }
244
+ function getTabId() {
245
+ if (typeof sessionStorage === 'undefined') {
246
+ return crypto.randomUUID();
247
+ }
248
+ const tabId = sessionStorage.getItem('tab-id');
249
+ if (tabId) {
250
+ const tabClosed = sessionStorage.getItem('tab-closed');
251
+ if (tabClosed === 'true') {
252
+ return tabId;
253
+ }
254
+
255
+ // fall through to generate a new tab id
256
+ }
257
+ const newTabId = crypto.randomUUID();
258
+ sessionStorage.setItem('tab-id', newTabId);
259
+ return newTabId;
260
+ }
261
+
262
+ /**
263
+ * A unique identifier for the current browser tab
264
+ * useful for observability/tracing and deduping
265
+ * across multiple tabs.
266
+ *
267
+ * @group Constants
268
+ */
269
+ const TAB_ID = getTabId();
270
+ /**
271
+ * The epoch seconds at which the tab id was generated
272
+ *
273
+ * @group Constants
274
+ */
275
+ const TAB_ASSIGNED = Math.floor(Date.now() / 1000);
276
+
277
+ /**
278
+ * Adds the `X-Amzn-Trace-Id` header to support observability
279
+ * tooling around request routing.
280
+ *
281
+ * This makes use of the {@link TAB_ID} and {@link TAB_ASSIGNED}
282
+ * to enable tracking the browser tab of origin across multiple requests.
283
+ *
284
+ * Follows the template: `Root=1-${now}-${uuidv4};TabId=1-${epochSeconds}-${tab-uuid}`
285
+ *
286
+ * @group Utility Functions
287
+ */
288
+ function addTraceHeader(headers) {
289
+ const now = Math.floor(Date.now() / 1000);
290
+ headers.set('X-Amzn-Trace-Id', `Root=1-${now}-${crypto.randomUUID()};TabId=1-${TAB_ASSIGNED}-${TAB_ID}`);
291
+ return headers;
292
+ }
293
+
294
+ /**
295
+ * Source: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html
296
+ * As of 2024-12-05 the maximum URL length is 8192 bytes.
297
+ *
298
+ * @group Constants
299
+ */
300
+ const MAX_URL_LENGTH = 8192;
301
+
302
+ /**
303
+ * This assertion takes a URL and throws an error if the URL is longer than the maximum URL length.
304
+ *
305
+ * See also {@link MAX_URL_LENGTH}
306
+ *
307
+ * @group Utility Functions
308
+ */
309
+ function assertInvalidUrlLength(url) {
310
+ (test => {
311
+ if (!test) {
312
+ throw new Error(`URL length ${url?.length} exceeds the maximum URL length of ${MAX_URL_LENGTH} bytes.\n\nConsider converting this request query a \`/query\` endpoint instead of a GET, or upgrade the current endpoint to be able to receive a POST request directly (ideally specifying the header HTTP-Method-Override: QUERY)\n\nThe Invalid URL is:\n\n${url}`);
313
+ }
314
+ })(!url || url.length <= MAX_URL_LENGTH);
315
+ }
316
+ export { AutoCompress, Gate, MAX_URL_LENGTH, MetaDocHandler, SupportsRequestStreams, TAB_ASSIGNED, TAB_ID, addTraceHeader, assertInvalidUrlLength };
@@ -0,0 +1,362 @@
1
+ import { getOrSetGlobal } from '@warp-drive-mirror/core/types/-private';
2
+
3
+ /**
4
+ * @module
5
+ * @mergeModuleWith <project>
6
+ */
7
+
8
+ // prevents the final constructed object from needing to add
9
+ // host and namespace which are provided by the final consuming
10
+ // class to the prototype which can result in overwrite errors
11
+
12
+ const CONFIG = getOrSetGlobal('CONFIG', {
13
+ host: '',
14
+ namespace: ''
15
+ });
16
+
17
+ /**
18
+ * Sets the global configuration for `buildBaseURL`
19
+ * for host and namespace values for the application.
20
+ *
21
+ * These values may still be overridden by passing
22
+ * them to buildBaseURL directly.
23
+ *
24
+ * This method may be called as many times as needed.
25
+ * host values of `''` or `'/'` are equivalent.
26
+ *
27
+ * Except for the value of `/` as host, host should not
28
+ * end with `/`.
29
+ *
30
+ * namespace should not start or end with a `/`.
31
+ *
32
+ * ```ts
33
+ * type BuildURLConfig = {
34
+ * host: string;
35
+ * namespace: string'
36
+ * }
37
+ * ```
38
+ *
39
+ * Example:
40
+ *
41
+ * ```ts
42
+ * import { setBuildURLConfig } from '@ember-data-mirror/request-utils';
43
+ *
44
+ * setBuildURLConfig({
45
+ * host: 'https://api.example.com',
46
+ * namespace: 'api/v1'
47
+ * });
48
+ * ```
49
+ *
50
+ * @public
51
+ */
52
+ function setBuildURLConfig(config) {
53
+ (test => {
54
+ if (!test) {
55
+ throw new Error(`setBuildURLConfig: You must pass a config object`);
56
+ }
57
+ })(config);
58
+ (test => {
59
+ if (!test) {
60
+ throw new Error(`setBuildURLConfig: You must pass a config object with a 'host' or 'namespace' property`);
61
+ }
62
+ })('host' in config || 'namespace' in config);
63
+ CONFIG.host = config.host || '';
64
+ CONFIG.namespace = config.namespace || '';
65
+ (test => {
66
+ if (!test) {
67
+ throw new Error(`buildBaseURL: host must NOT end with '/', received '${CONFIG.host}'`);
68
+ }
69
+ })(CONFIG.host === '/' || !CONFIG.host.endsWith('/'));
70
+ (test => {
71
+ if (!test) {
72
+ throw new Error(`buildBaseURL: namespace must NOT start with '/', received '${CONFIG.namespace}'`);
73
+ }
74
+ })(!CONFIG.namespace.startsWith('/'));
75
+ (test => {
76
+ if (!test) {
77
+ throw new Error(`buildBaseURL: namespace must NOT end with '/', received '${CONFIG.namespace}'`);
78
+ }
79
+ })(!CONFIG.namespace.endsWith('/'));
80
+ }
81
+ const OPERATIONS_WITH_PRIMARY_RECORDS = new Set(['findRecord', 'findRelatedRecord', 'findRelatedCollection', 'updateRecord', 'deleteRecord']);
82
+ function isOperationWithPrimaryRecord(options) {
83
+ return 'op' in options && OPERATIONS_WITH_PRIMARY_RECORDS.has(options.op);
84
+ }
85
+ function hasResourcePath(options) {
86
+ return 'resourcePath' in options && typeof options.resourcePath === 'string' && options.resourcePath.length > 0;
87
+ }
88
+ function resourcePathForType(options) {
89
+ (test => {
90
+ if (!test) {
91
+ throw new Error(`resourcePathForType: You must pass a valid op as part of options`);
92
+ }
93
+ })('op' in options && typeof options.op === 'string');
94
+ return options.op === 'findMany' ? options.identifiers[0].type : options.identifier.type;
95
+ }
96
+
97
+ /**
98
+ * Builds a URL for a request based on the provided options.
99
+ * Does not include support for building query params (see `buildQueryParams`)
100
+ * so that it may be composed cleanly with other query-params strategies.
101
+ *
102
+ * Usage:
103
+ *
104
+ * ```ts
105
+ * import { buildBaseURL } from '@ember-data-mirror/request-utils';
106
+ *
107
+ * const url = buildBaseURL({
108
+ * host: 'https://api.example.com',
109
+ * namespace: 'api/v1',
110
+ * resourcePath: 'emberDevelopers',
111
+ * op: 'query',
112
+ * identifier: { type: 'ember-developer' }
113
+ * });
114
+ *
115
+ * // => 'https://api.example.com/api/v1/emberDevelopers'
116
+ * ```
117
+ *
118
+ * On the surface this may seem like a lot of work to do something simple, but
119
+ * it is designed to be composable with other utilities and interfaces that the
120
+ * average product engineer will never need to see or use.
121
+ *
122
+ * A few notes:
123
+ *
124
+ * - `resourcePath` is optional, but if it is not provided, `identifier.type` will be used.
125
+ * - `host` and `namespace` are optional, but if they are not provided, the values globally
126
+ * configured via `setBuildURLConfig` will be used.
127
+ * - `op` is required and must be one of the following:
128
+ * - 'findRecord' 'query' 'findMany' 'findRelatedCollection' 'findRelatedRecord'` 'createRecord' 'updateRecord' 'deleteRecord'
129
+ * - Depending on the value of `op`, `identifier` or `identifiers` will be required.
130
+ *
131
+ * @public
132
+ */
133
+ function buildBaseURL(urlOptions) {
134
+ const options = Object.assign({
135
+ host: CONFIG.host,
136
+ namespace: CONFIG.namespace
137
+ }, urlOptions);
138
+ (test => {
139
+ if (!test) {
140
+ throw new Error(`buildBaseURL: You must pass \`op\` as part of options`);
141
+ }
142
+ })(hasResourcePath(options) || typeof options.op === 'string' && options.op.length > 0);
143
+ (test => {
144
+ if (!test) {
145
+ throw new Error(`buildBaseURL: You must pass \`identifier\` as part of options`);
146
+ }
147
+ })(hasResourcePath(options) || options.op === 'findMany' || options.identifier && typeof options.identifier === 'object');
148
+ (test => {
149
+ if (!test) {
150
+ throw new Error(`buildBaseURL: You must pass \`identifiers\` as part of options`);
151
+ }
152
+ })(hasResourcePath(options) || options.op !== 'findMany' || options.identifiers && Array.isArray(options.identifiers) && options.identifiers.length > 0 && options.identifiers.every(i => i && typeof i === 'object'));
153
+ (test => {
154
+ if (!test) {
155
+ throw new Error(`buildBaseURL: You must pass valid \`identifier\` as part of options, expected 'id'`);
156
+ }
157
+ })(hasResourcePath(options) || !isOperationWithPrimaryRecord(options) || typeof options.identifier.id === 'string' && options.identifier.id.length > 0);
158
+ (test => {
159
+ if (!test) {
160
+ throw new Error(`buildBaseURL: You must pass \`identifiers\` as part of options`);
161
+ }
162
+ })(hasResourcePath(options) || options.op !== 'findMany' || options.identifiers.every(i => typeof i.id === 'string' && i.id.length > 0));
163
+ (test => {
164
+ if (!test) {
165
+ throw new Error(`buildBaseURL: You must pass valid \`identifier\` as part of options, expected 'type'`);
166
+ }
167
+ })(hasResourcePath(options) || options.op === 'findMany' || typeof options.identifier.type === 'string' && options.identifier.type.length > 0);
168
+ (test => {
169
+ if (!test) {
170
+ throw new Error(`buildBaseURL: You must pass valid \`identifiers\` as part of options, expected 'type'`);
171
+ }
172
+ })(hasResourcePath(options) || options.op !== 'findMany' || typeof options.identifiers[0].type === 'string' && options.identifiers[0].type.length > 0);
173
+
174
+ // prettier-ignore
175
+ const idPath = isOperationWithPrimaryRecord(options) ? encodeURIComponent(options.identifier.id) : '';
176
+ const resourcePath = options.resourcePath || resourcePathForType(options);
177
+ const {
178
+ host,
179
+ namespace
180
+ } = options;
181
+ const fieldPath = 'fieldPath' in options ? options.fieldPath : '';
182
+ (test => {
183
+ if (!test) {
184
+ throw new Error(`buildBaseURL: You tried to build a url for a ${String('op' in options ? options.op + ' ' : '')}request to ${resourcePath} but resourcePath must be set or op must be one of "${['findRecord', 'findRelatedRecord', 'findRelatedCollection', 'updateRecord', 'deleteRecord', 'createRecord', 'query', 'findMany'].join('","')}".`);
185
+ }
186
+ })(hasResourcePath(options) || ['findRecord', 'query', 'findMany', 'findRelatedCollection', 'findRelatedRecord', 'createRecord', 'updateRecord', 'deleteRecord'].includes(options.op));
187
+ (test => {
188
+ if (!test) {
189
+ throw new Error(`buildBaseURL: host must NOT end with '/', received '${host}'`);
190
+ }
191
+ })(host === '/' || !host.endsWith('/'));
192
+ (test => {
193
+ if (!test) {
194
+ throw new Error(`buildBaseURL: namespace must NOT start with '/', received '${namespace}'`);
195
+ }
196
+ })(!namespace.startsWith('/'));
197
+ (test => {
198
+ if (!test) {
199
+ throw new Error(`buildBaseURL: namespace must NOT end with '/', received '${namespace}'`);
200
+ }
201
+ })(!namespace.endsWith('/'));
202
+ (test => {
203
+ if (!test) {
204
+ throw new Error(`buildBaseURL: resourcePath must NOT start with '/', received '${resourcePath}'`);
205
+ }
206
+ })(!resourcePath.startsWith('/'));
207
+ (test => {
208
+ if (!test) {
209
+ throw new Error(`buildBaseURL: resourcePath must NOT end with '/', received '${resourcePath}'`);
210
+ }
211
+ })(!resourcePath.endsWith('/'));
212
+ (test => {
213
+ if (!test) {
214
+ throw new Error(`buildBaseURL: fieldPath must NOT start with '/', received '${fieldPath}'`);
215
+ }
216
+ })(!fieldPath.startsWith('/'));
217
+ (test => {
218
+ if (!test) {
219
+ throw new Error(`buildBaseURL: fieldPath must NOT end with '/', received '${fieldPath}'`);
220
+ }
221
+ })(!fieldPath.endsWith('/'));
222
+ (test => {
223
+ if (!test) {
224
+ throw new Error(`buildBaseURL: idPath must NOT start with '/', received '${idPath}'`);
225
+ }
226
+ })(!idPath.startsWith('/'));
227
+ (test => {
228
+ if (!test) {
229
+ throw new Error(`buildBaseURL: idPath must NOT end with '/', received '${idPath}'`);
230
+ }
231
+ })(!idPath.endsWith('/'));
232
+ const hasHost = host !== '' && host !== '/';
233
+ const url = [hasHost ? host : '', namespace, resourcePath, idPath, fieldPath].filter(Boolean).join('/');
234
+ return hasHost ? url : `/${url}`;
235
+ }
236
+ const DEFAULT_QUERY_PARAMS_SERIALIZATION_OPTIONS = {
237
+ arrayFormat: 'comma'
238
+ };
239
+ function handleInclude(include) {
240
+ (test => {
241
+ if (!test) {
242
+ throw new Error(`Expected include to be a string or array, got ${typeof include}`);
243
+ }
244
+ })(typeof include === 'string' || Array.isArray(include));
245
+ return typeof include === 'string' ? include.split(',') : include;
246
+ }
247
+
248
+ /**
249
+ * filter out keys of an object that have falsy values or point to empty arrays
250
+ * returning a new object with only those keys that have truthy values / non-empty arrays
251
+ *
252
+ * @public
253
+ * @param source object to filter keys with empty values from
254
+ * @return A new object with the keys that contained empty values removed
255
+ */
256
+ function filterEmpty(source) {
257
+ const result = {};
258
+ for (const key in source) {
259
+ const value = source[key];
260
+ // Allow `0` and `false` but filter falsy values that indicate "empty"
261
+ if (value !== undefined && value !== null && value !== '') {
262
+ if (!Array.isArray(value) || value.length > 0) {
263
+ result[key] = source[key];
264
+ }
265
+ }
266
+ }
267
+ return result;
268
+ }
269
+
270
+ /**
271
+ * Sorts query params by both key and value returning a new URLSearchParams
272
+ * object with the keys inserted in sorted order.
273
+ *
274
+ * Treats `included` specially, splicing it into an array if it is a string and sorting the array.
275
+ *
276
+ * Options:
277
+ * - arrayFormat: 'bracket' | 'indices' | 'repeat' | 'comma'
278
+ *
279
+ * 'bracket': appends [] to the key for every value e.g. `&ids[]=1&ids[]=2`
280
+ * 'indices': appends [i] to the key for every value e.g. `&ids[0]=1&ids[1]=2`
281
+ * 'repeat': appends the key for every value e.g. `&ids=1&ids=2`
282
+ * 'comma' (default): appends the key once with a comma separated list of values e.g. `&ids=1,2`
283
+ *
284
+ * @public
285
+ * @return A {@link URLSearchParams} with keys inserted in sorted order
286
+ */
287
+ function sortQueryParams(params, options) {
288
+ const opts = Object.assign({}, DEFAULT_QUERY_PARAMS_SERIALIZATION_OPTIONS, options);
289
+ const paramsIsObject = !(params instanceof URLSearchParams);
290
+ const urlParams = new URLSearchParams();
291
+ const dictionaryParams = paramsIsObject ? params : {};
292
+ if (!paramsIsObject) {
293
+ params.forEach((value, key) => {
294
+ const hasExisting = key in dictionaryParams;
295
+ if (!hasExisting) {
296
+ dictionaryParams[key] = value;
297
+ } else {
298
+ const existingValue = dictionaryParams[key];
299
+ if (Array.isArray(existingValue)) {
300
+ existingValue.push(value);
301
+ } else {
302
+ dictionaryParams[key] = [existingValue, value];
303
+ }
304
+ }
305
+ });
306
+ }
307
+ if ('include' in dictionaryParams) {
308
+ dictionaryParams.include = handleInclude(dictionaryParams.include);
309
+ }
310
+ const sortedKeys = Object.keys(dictionaryParams).sort();
311
+ sortedKeys.forEach(key => {
312
+ const value = dictionaryParams[key];
313
+ if (Array.isArray(value)) {
314
+ value.sort();
315
+ switch (opts.arrayFormat) {
316
+ case 'indices':
317
+ value.forEach((v, i) => {
318
+ urlParams.append(`${key}[${i}]`, String(v));
319
+ });
320
+ return;
321
+ case 'bracket':
322
+ value.forEach(v => {
323
+ urlParams.append(`${key}[]`, String(v));
324
+ });
325
+ return;
326
+ case 'repeat':
327
+ value.forEach(v => {
328
+ urlParams.append(key, String(v));
329
+ });
330
+ return;
331
+ case 'comma':
332
+ default:
333
+ urlParams.append(key, value.join(','));
334
+ return;
335
+ }
336
+ } else {
337
+ urlParams.append(key, String(value));
338
+ }
339
+ });
340
+ return urlParams;
341
+ }
342
+
343
+ /**
344
+ * Sorts query params by both key and value, returning a query params string
345
+ *
346
+ * Treats `included` specially, splicing it into an array if it is a string and sorting the array.
347
+ *
348
+ * Options:
349
+ * - arrayFormat: 'bracket' | 'indices' | 'repeat' | 'comma'
350
+ *
351
+ * 'bracket': appends [] to the key for every value e.g. `ids[]=1&ids[]=2`
352
+ * 'indices': appends [i] to the key for every value e.g. `ids[0]=1&ids[1]=2`
353
+ * 'repeat': appends the key for every value e.g. `ids=1&ids=2`
354
+ * 'comma' (default): appends the key once with a comma separated list of values e.g. `ids=1,2`
355
+ *
356
+ * @public
357
+ * @return A sorted query params string without the leading `?`
358
+ */
359
+ function buildQueryParams(params, options) {
360
+ return sortQueryParams(params, options).toString();
361
+ }
362
+ export { buildBaseURL, buildQueryParams, filterEmpty, setBuildURLConfig, sortQueryParams };