@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.
- package/README.md +16 -25
- package/declarations/-private/active-record/find-record.d.ts +5 -6
- package/declarations/-private/active-record/query.d.ts +2 -2
- package/declarations/-private/active-record/save-record.d.ts +2 -2
- package/declarations/-private/handlers/meta-doc.d.ts +1 -1
- package/declarations/-private/json-api/find-record.d.ts +37 -18
- package/declarations/-private/json-api/query.d.ts +3 -6
- package/declarations/-private/json-api/save-record.d.ts +45 -10
- package/declarations/-private/json-api/serialize.d.ts +15 -1
- package/declarations/-private/rest/find-record.d.ts +5 -8
- package/declarations/-private/rest/query.d.ts +2 -2
- package/declarations/-private/rest/save-record.d.ts +2 -2
- package/dist/active-record.js +2 -0
- package/dist/handlers.js +3 -3
- package/dist/json-api.js +94 -26
- package/dist/rest.js +2 -2
- package/dist/string.js +428 -1
- package/dist/unpkg/dev/-private.js +7 -0
- package/dist/unpkg/dev/active-record.js +394 -0
- package/dist/unpkg/dev/builder-utils-Donkk-BZ.js +22 -0
- package/dist/unpkg/dev/derivations.js +30 -0
- package/dist/unpkg/dev/handlers.js +316 -0
- package/dist/unpkg/dev/index.js +362 -0
- package/dist/unpkg/dev/inflect-BEv8WqY1.js +343 -0
- package/dist/unpkg/dev/json-api.js +740 -0
- package/dist/unpkg/dev/rest.js +392 -0
- package/dist/unpkg/dev/string.js +1 -0
- package/dist/unpkg/dev-deprecated/-private.js +7 -0
- package/dist/unpkg/dev-deprecated/active-record.js +394 -0
- package/dist/unpkg/dev-deprecated/builder-utils-Donkk-BZ.js +22 -0
- package/dist/unpkg/dev-deprecated/derivations.js +30 -0
- package/dist/unpkg/dev-deprecated/handlers.js +316 -0
- package/dist/unpkg/dev-deprecated/index.js +362 -0
- package/dist/unpkg/dev-deprecated/inflect-BEv8WqY1.js +343 -0
- package/dist/unpkg/dev-deprecated/json-api.js +740 -0
- package/dist/unpkg/dev-deprecated/rest.js +392 -0
- package/dist/unpkg/dev-deprecated/string.js +1 -0
- package/dist/unpkg/prod/-private.js +7 -0
- package/dist/unpkg/prod/active-record.js +366 -0
- package/dist/unpkg/prod/builder-utils-Donkk-BZ.js +22 -0
- package/dist/unpkg/prod/derivations.js +30 -0
- package/dist/unpkg/prod/handlers.js +305 -0
- package/dist/unpkg/prod/index.js +239 -0
- package/dist/unpkg/prod/inflect-Dh9dyEYx.js +333 -0
- package/dist/unpkg/prod/json-api.js +702 -0
- package/dist/unpkg/prod/rest.js +364 -0
- package/dist/unpkg/prod/string.js +1 -0
- package/dist/unpkg/prod-deprecated/-private.js +7 -0
- package/dist/unpkg/prod-deprecated/active-record.js +366 -0
- package/dist/unpkg/prod-deprecated/builder-utils-Donkk-BZ.js +22 -0
- package/dist/unpkg/prod-deprecated/derivations.js +30 -0
- package/dist/unpkg/prod-deprecated/handlers.js +305 -0
- package/dist/unpkg/prod-deprecated/index.js +239 -0
- package/dist/unpkg/prod-deprecated/inflect-Dh9dyEYx.js +333 -0
- package/dist/unpkg/prod-deprecated/json-api.js +702 -0
- package/dist/unpkg/prod-deprecated/rest.js +364 -0
- package/dist/unpkg/prod-deprecated/string.js +1 -0
- package/logos/README.md +2 -2
- package/logos/logo-yellow-slab.svg +1 -0
- package/logos/word-mark-black.svg +1 -0
- package/logos/word-mark-white.svg +1 -0
- package/package.json +13 -5
- package/logos/NCC-1701-a-blue.svg +0 -4
- package/logos/NCC-1701-a-gold.svg +0 -4
- package/logos/NCC-1701-a-gold_100.svg +0 -1
- package/logos/NCC-1701-a-gold_base-64.txt +0 -1
- package/logos/NCC-1701-a.svg +0 -4
- package/logos/docs-badge.svg +0 -2
- package/logos/ember-data-logo-dark.svg +0 -12
- package/logos/ember-data-logo-light.svg +0 -12
- package/logos/social1.png +0 -0
- package/logos/social2.png +0 -0
- package/logos/warp-drive-logo-dark.svg +0 -4
- 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 };
|