@webqit/fetch-plus 0.1.2 → 0.1.4
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 +2050 -2
- package/dist/main.js +1 -1
- package/dist/main.js.map +4 -4
- package/package.json +8 -8
- package/src/FormDataPlus.js +23 -15
- package/src/HeadersPlus.js +128 -56
- package/src/LiveResponse.js +233 -155
- package/src/RequestPlus.js +9 -7
- package/src/ResponsePlus.js +6 -6
- package/src/index.js +0 -1
- package/src/messageParserMixin.js +217 -0
- package/test/1.basic.test.js +314 -0
- package/test/2.LiveResponse.test.js +261 -0
- package/test/3.LiveResponse.integration.test.js +459 -0
- package/src/URLSearchParamsPlus.js +0 -80
- package/src/core.js +0 -172
- package/test/basic.test.js +0 -0
package/src/RequestPlus.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { messageParserMixin, _meta, _wq } from './
|
|
1
|
+
import { messageParserMixin, _meta, _wq } from './messageParserMixin.js';
|
|
2
2
|
import { HeadersPlus } from './HeadersPlus.js';
|
|
3
3
|
|
|
4
4
|
export class RequestPlus extends messageParserMixin(Request) {
|
|
@@ -25,8 +25,8 @@ export class RequestPlus extends messageParserMixin(Request) {
|
|
|
25
25
|
$type = $$type;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const instance = new this
|
|
29
|
-
|
|
28
|
+
const instance = new this(url, init);
|
|
29
|
+
|
|
30
30
|
if (memoize) {
|
|
31
31
|
const cache = _meta(instance, 'cache');
|
|
32
32
|
const typeMap = { json: 'json', FormData: 'formData', text: 'text', ArrayBuffer: 'arrayBuffer', Blob: 'blob', Bytes: 'bytes' };
|
|
@@ -48,7 +48,7 @@ export class RequestPlus extends messageParserMixin(Request) {
|
|
|
48
48
|
: request[prop])
|
|
49
49
|
}
|
|
50
50
|
), {});
|
|
51
|
-
if (!['GET', 'HEAD'].includes(
|
|
51
|
+
if (!['GET', 'HEAD'].includes(requestInit.method.toUpperCase())) {
|
|
52
52
|
if ('body' in init) {
|
|
53
53
|
requestInit.body = init.body
|
|
54
54
|
if (!('headers' in init)) {
|
|
@@ -58,11 +58,13 @@ export class RequestPlus extends messageParserMixin(Request) {
|
|
|
58
58
|
} else {
|
|
59
59
|
requestInit.body = await request.clone().arrayBuffer();
|
|
60
60
|
}
|
|
61
|
+
} else {
|
|
62
|
+
requestInit.body = null;
|
|
61
63
|
}
|
|
62
64
|
if (requestInit.mode === 'navigate') {
|
|
63
65
|
requestInit.mode = 'cors';
|
|
64
66
|
}
|
|
65
|
-
return { url: request.url, ...requestInit };
|
|
67
|
+
return { url: init.url || request.url, ...requestInit };
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
clone() {
|
|
@@ -71,8 +73,8 @@ export class RequestPlus extends messageParserMixin(Request) {
|
|
|
71
73
|
|
|
72
74
|
const requestMeta = _meta(this);
|
|
73
75
|
_wq(clone).set('meta', new Map(requestMeta));
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
+
if (_meta(clone).has('cache')) {
|
|
77
|
+
_meta(clone).set('cache', new Map(requestMeta.get('cache')));
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
return clone;
|
package/src/ResponsePlus.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { messageParserMixin, _meta, _wq } from './
|
|
1
|
+
import { messageParserMixin, _meta, _wq } from './messageParserMixin.js';
|
|
2
2
|
import { HeadersPlus } from './HeadersPlus.js';
|
|
3
3
|
|
|
4
4
|
export class ResponsePlus extends messageParserMixin(Response) {
|
|
@@ -18,14 +18,14 @@ export class ResponsePlus extends messageParserMixin(Response) {
|
|
|
18
18
|
static from(body, { memoize = false, ...init } = {}) {
|
|
19
19
|
if (body instanceof Response) return body;
|
|
20
20
|
|
|
21
|
-
let $type
|
|
22
|
-
if (
|
|
21
|
+
let $type;
|
|
22
|
+
if (typeof body !== 'undefined') {
|
|
23
23
|
let headers;
|
|
24
24
|
({ body, headers, $type } = super.from({ body, headers: init.headers }));
|
|
25
25
|
init = { ...init, headers };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const instance = new this
|
|
28
|
+
const instance = new this(body, init);
|
|
29
29
|
|
|
30
30
|
if (memoize) {
|
|
31
31
|
const cache = _meta(instance, 'cache');
|
|
@@ -47,8 +47,8 @@ export class ResponsePlus extends messageParserMixin(Response) {
|
|
|
47
47
|
|
|
48
48
|
const responseMeta = _meta(this);
|
|
49
49
|
_wq(clone).set('meta', new Map(responseMeta));
|
|
50
|
-
if (
|
|
51
|
-
|
|
50
|
+
if (_meta(clone).has('cache')) {
|
|
51
|
+
_meta(clone).set('cache', new Map(responseMeta.get('cache')));
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
return clone;
|
package/src/index.js
CHANGED
|
@@ -4,5 +4,4 @@ export { ResponsePlus } from './ResponsePlus.js';
|
|
|
4
4
|
export { HeadersPlus } from './HeadersPlus.js';
|
|
5
5
|
export { FormDataPlus } from './FormDataPlus.js';
|
|
6
6
|
export { LiveResponse } from './LiveResponse.js';
|
|
7
|
-
export { URLSearchParamsPlus } from './URLSearchParamsPlus.js';
|
|
8
7
|
export { default as Observer } from '@webqit/observer';
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { _isString, _isObject, _isTypeObject, _isNumber, _isBoolean, _isPlainObject, _isPlainArray } from '@webqit/util/js/index.js';
|
|
2
|
+
import { _wq as $wq } from '@webqit/util/js/index.js';
|
|
3
|
+
import { FormDataPlus } from './FormDataPlus.js';
|
|
4
|
+
|
|
5
|
+
export const _wq = (target, ...args) => $wq(target, 'fetch+', ...args);
|
|
6
|
+
export const _meta = (target, ...args) => $wq(target, 'fetch+', 'meta', ...args);
|
|
7
|
+
|
|
8
|
+
export function messageParserMixin(superClass) {
|
|
9
|
+
return class extends superClass {
|
|
10
|
+
|
|
11
|
+
static from(httpMessageInit) {
|
|
12
|
+
const headers = (httpMessageInit.headers instanceof Headers)
|
|
13
|
+
|
|
14
|
+
? [...httpMessageInit.headers.entries()].reduce((_headers, [name, value]) => {
|
|
15
|
+
const key = name.toLowerCase();
|
|
16
|
+
_headers[key] = _headers[key] ? [].concat(_headers[key], value) : value;
|
|
17
|
+
return _headers;
|
|
18
|
+
}, {})
|
|
19
|
+
|
|
20
|
+
: Object.keys(httpMessageInit.headers || {}).reduce((_headers, name) => {
|
|
21
|
+
_headers[name.toLowerCase()] = httpMessageInit.headers[name];
|
|
22
|
+
return _headers;
|
|
23
|
+
}, {});
|
|
24
|
+
|
|
25
|
+
// Process body
|
|
26
|
+
let body = httpMessageInit.body;
|
|
27
|
+
let type = dataType(body);
|
|
28
|
+
|
|
29
|
+
// Binary bodies
|
|
30
|
+
if (['Blob', 'File'].includes(type)) {
|
|
31
|
+
|
|
32
|
+
headers['content-type'] ??= body.type;
|
|
33
|
+
headers['content-length'] ??= body.size;
|
|
34
|
+
|
|
35
|
+
} else if (['Uint8Array', 'Uint16Array', 'Uint32Array', 'ArrayBuffer'].includes(type)) {
|
|
36
|
+
headers['content-length'] ??= body.byteLength;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// JSON objects
|
|
40
|
+
else if (type === 'json' && _isTypeObject(body)) {
|
|
41
|
+
const { result: _body, isDirectlySerializable } = FormDataPlus.json(body, { encodeLiterals: true, meta: true });
|
|
42
|
+
if (isDirectlySerializable) {
|
|
43
|
+
|
|
44
|
+
body = JSON.stringify(body, (k, v) => v instanceof Error ? { ...v, message: v.message } : v);
|
|
45
|
+
headers['content-type'] = 'application/json';
|
|
46
|
+
headers['content-length'] = (new Blob([body])).size;
|
|
47
|
+
|
|
48
|
+
} else {
|
|
49
|
+
body = _body;
|
|
50
|
+
type = 'FormData';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Strings
|
|
55
|
+
else if (['text', 'json'].includes(type) && !headers['content-length']) {
|
|
56
|
+
headers['content-length'] = (new Blob([body])).size;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!['FormData'].includes(type)
|
|
60
|
+
&& !['function'].includes(typeof body)
|
|
61
|
+
&& !headers['content-type']) {
|
|
62
|
+
headers['content-type'] = 'application/octet-stream';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Return canonical init object with type info
|
|
66
|
+
return { body, headers: new Headers(headers), $type: type };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async formData() {
|
|
70
|
+
const fd = await super.formData();
|
|
71
|
+
FormDataPlus.upgradeInPlace(fd);
|
|
72
|
+
return fd;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async any({ to = null, memo = false } = {}) {
|
|
76
|
+
if (to && ![
|
|
77
|
+
'blob', 'text', 'json', 'arrayBuffer', 'bytes', 'formData'
|
|
78
|
+
].includes(to)) throw new Error(`Invalid target type specified: ${to}`);
|
|
79
|
+
|
|
80
|
+
const cache = _meta(this, 'cache');
|
|
81
|
+
const readAs = async (type) => {
|
|
82
|
+
// 1. Direct parsing
|
|
83
|
+
if (!memo) return await this[type || 'bytes']();
|
|
84
|
+
|
|
85
|
+
const byValue = (x) => {
|
|
86
|
+
if (x instanceof FormData) {
|
|
87
|
+
const clone = new FormDataPlus;
|
|
88
|
+
for (const [k, v] of x.entries()) clone.append(k, v);
|
|
89
|
+
return clone;
|
|
90
|
+
}
|
|
91
|
+
if ((!type || type === 'json')
|
|
92
|
+
&& (_isPlainObject(x) || _isPlainArray(x))) {
|
|
93
|
+
return structuredClone(x);
|
|
94
|
+
}
|
|
95
|
+
return x;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// 2. Direct original
|
|
99
|
+
if (!type && cache.has('original')) return byValue(cache.has('original'));
|
|
100
|
+
|
|
101
|
+
// Default type
|
|
102
|
+
type ??= 'bytes';
|
|
103
|
+
|
|
104
|
+
// 3. Direct cache matching
|
|
105
|
+
if (cache.has(type)) return byValue(cache.get(type));
|
|
106
|
+
|
|
107
|
+
// 4. Clone + parse
|
|
108
|
+
let result;
|
|
109
|
+
if (cache.has('memo')) {
|
|
110
|
+
result = cache.get('memo').clone()[type]();
|
|
111
|
+
} else {
|
|
112
|
+
cache.set('memo', this.clone());
|
|
113
|
+
result = await this[type]();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
cache.set(type, result);
|
|
117
|
+
|
|
118
|
+
return byValue(result);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const contentType = (this.headers.get('Content-Type') || '').split(';')[0].trim();
|
|
122
|
+
let result;
|
|
123
|
+
if ((!to || ['formData', 'json'].includes(to))
|
|
124
|
+
&& ['multipart/form-data', 'application/x-www-form-urlencoded'].includes(contentType)) {
|
|
125
|
+
let fd = await readAs('formData');
|
|
126
|
+
FormDataPlus.upgradeInPlace(fd);
|
|
127
|
+
|
|
128
|
+
if (to === 'json') {
|
|
129
|
+
fd = await fd.json({ decodeLiterals: true });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
result = fd;
|
|
133
|
+
} else if ((!to || ['formData', 'json'].includes(to))
|
|
134
|
+
&& contentType === 'application/json') {
|
|
135
|
+
let json = await readAs('json');
|
|
136
|
+
|
|
137
|
+
if (to === 'formData') {
|
|
138
|
+
json = FormDataPlus.json(json, { encodeLiterals: true });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
result = json;
|
|
142
|
+
} else if (!to && (
|
|
143
|
+
contentType.startsWith('image/') ||
|
|
144
|
+
contentType.startsWith('video/') ||
|
|
145
|
+
contentType.startsWith('audio/') ||
|
|
146
|
+
(contentType.startsWith('application/')
|
|
147
|
+
&& !['xml', 'json', 'javascript', 'x-www-form-urlencoded'].some(t => contentType.includes(t)))
|
|
148
|
+
)) {
|
|
149
|
+
result = await readAs('blob');
|
|
150
|
+
} else if (!to && (
|
|
151
|
+
contentType.startsWith('text/') ||
|
|
152
|
+
(contentType.startsWith('application/')
|
|
153
|
+
&& ['xml', 'javascript'].some((t) => contentType.includes(t))
|
|
154
|
+
))) {
|
|
155
|
+
result = await readAs('text');
|
|
156
|
+
} else {
|
|
157
|
+
if (['json', 'formData'].includes(to)) {
|
|
158
|
+
throw new Error(`Cannot convert body of type ${contentType} to ${to}`);
|
|
159
|
+
}
|
|
160
|
+
result = await readAs(to);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
forget() {
|
|
167
|
+
const cache = _meta(this, 'cache');
|
|
168
|
+
cache.clear();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ------ Util
|
|
174
|
+
|
|
175
|
+
export function dataType(value) {
|
|
176
|
+
if (value instanceof FormData) {
|
|
177
|
+
return 'FormData';
|
|
178
|
+
}
|
|
179
|
+
if (value === null || _isNumber(value) || _isBoolean(value)) {
|
|
180
|
+
return 'json';
|
|
181
|
+
}
|
|
182
|
+
if (_isString(value)) {
|
|
183
|
+
return 'text';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (_isTypeObject(value)) {
|
|
187
|
+
const toStringTag = value[Symbol.toStringTag];
|
|
188
|
+
const type = [
|
|
189
|
+
'Uint8Array', 'Uint16Array', 'Uint32Array', 'ArrayBuffer', 'Blob', 'File', 'FormData', 'Stream', 'ReadableStream'
|
|
190
|
+
].reduce((_toStringTag, type) => _toStringTag || (toStringTag === type ? type : null), null);
|
|
191
|
+
|
|
192
|
+
if (type) return type;
|
|
193
|
+
|
|
194
|
+
if (_isObject(value) || Array.isArray(value)) {
|
|
195
|
+
return 'json';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if ('toString' in value) return 'text';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function isTypeReadable(obj) {
|
|
205
|
+
return (
|
|
206
|
+
obj !== null &&
|
|
207
|
+
typeof obj === 'object' &&
|
|
208
|
+
typeof obj.read === 'function' && // streams have .read()
|
|
209
|
+
typeof obj.pipe === 'function' && // streams have .pipe()
|
|
210
|
+
typeof obj.on === 'function' // streams have event listeners
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function isTypeStream(obj) {
|
|
215
|
+
return obj instanceof ReadableStream
|
|
216
|
+
|| isTypeReadable(obj);
|
|
217
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import { HeadersPlus } from '../src/HeadersPlus.js';
|
|
3
|
+
import { RequestPlus } from '../src/RequestPlus.js';
|
|
4
|
+
import { ResponsePlus } from '../src/ResponsePlus.js';
|
|
5
|
+
import { FormDataPlus } from '../src/FormDataPlus.js';
|
|
6
|
+
import { fetchPlus } from '../src/fetchPlus.js';
|
|
7
|
+
|
|
8
|
+
describe('Core API Tests', function () {
|
|
9
|
+
|
|
10
|
+
describe('HeadersPlus', function () {
|
|
11
|
+
it('should parse "Cookie" request headers', function () {
|
|
12
|
+
const headers = new HeadersPlus({
|
|
13
|
+
'Cookie': 'name=value; name2=value2'
|
|
14
|
+
});
|
|
15
|
+
const cookies = headers.get('Cookie', true);
|
|
16
|
+
expect(cookies).to.be.an('array').with.lengthOf(2);
|
|
17
|
+
expect(cookies[0]).to.deep.include({ name: 'name', value: 'value' });
|
|
18
|
+
expect(cookies[1]).to.deep.include({ name: 'name2', value: 'value2' });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should parse "Set-Cookie" response headers', function () {
|
|
22
|
+
// Note: Native Headers object often combines multiple Set-Cookie headers oddly or hides them,
|
|
23
|
+
// but HeadersPlus aims to handle them if possible or at least parse single strings.
|
|
24
|
+
// Let's test basic single parsing first as multiple Set-Cookie support varies by environment (Node vs Browser).
|
|
25
|
+
const headers = new HeadersPlus();
|
|
26
|
+
headers.append('Set-Cookie', 'session=123; Secure; Path=/');
|
|
27
|
+
|
|
28
|
+
// getSetCookie might be polyfilled or native
|
|
29
|
+
const cookies = headers.get('Set-Cookie', true);
|
|
30
|
+
expect(cookies).to.be.an('array');
|
|
31
|
+
expect(cookies[0]).to.deep.include({ name: 'session', value: '123' });
|
|
32
|
+
expect(cookies[0]).to.have.property('secure', true);
|
|
33
|
+
expect(cookies[0]).to.have.property('path', '/');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should parse "Range" headers', function () {
|
|
37
|
+
const headers = new HeadersPlus({
|
|
38
|
+
'Range': 'bytes=0-499, 500-999'
|
|
39
|
+
});
|
|
40
|
+
const ranges = headers.get('Range', true);
|
|
41
|
+
expect(ranges).to.have.lengthOf(2);
|
|
42
|
+
expect(ranges[0]).to.deep.equal([0, 499]);
|
|
43
|
+
expect(ranges[1]).to.deep.equal([500, 999]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should parse "Accept" headers and match mime types', function () {
|
|
47
|
+
const headers = new HeadersPlus({
|
|
48
|
+
'Accept': 'text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8'
|
|
49
|
+
});
|
|
50
|
+
const accept = headers.get('Accept', true);
|
|
51
|
+
|
|
52
|
+
expect(accept).to.deep.equal([
|
|
53
|
+
['text/html', 1],
|
|
54
|
+
['application/xhtml+xml', 1],
|
|
55
|
+
['application/xml', 0.9],
|
|
56
|
+
['*/*', 0.8],
|
|
57
|
+
]);
|
|
58
|
+
expect(accept.toString()).to.equal('text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8');
|
|
59
|
+
expect(accept.match('text/html')).to.equal(1);
|
|
60
|
+
expect(accept.match('application/xml')).to.equal(0.9);
|
|
61
|
+
expect(accept.match('image/png')).to.equal(0.8); // Matches */*
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('HeadersPlus (Extended)', function () {
|
|
66
|
+
it('should parse multi-range headers', function () {
|
|
67
|
+
const headers = new HeadersPlus({
|
|
68
|
+
'Range': 'bytes=0-50, 60-100'
|
|
69
|
+
});
|
|
70
|
+
const ranges = headers.get('Range', true);
|
|
71
|
+
expect(ranges).to.have.lengthOf(2);
|
|
72
|
+
expect(ranges[0]).to.deep.equal([0, 50]);
|
|
73
|
+
expect(ranges[1]).to.deep.equal([60, 100]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should validate and render ranges', function () {
|
|
77
|
+
const headers = new HeadersPlus({
|
|
78
|
+
'Range': 'bytes=0-500'
|
|
79
|
+
});
|
|
80
|
+
const range = headers.get('Range', true)[0];
|
|
81
|
+
|
|
82
|
+
// Validate against total length
|
|
83
|
+
expect(range.canResolveAgainst(0, 1000)).to.be.true;
|
|
84
|
+
expect(range.canResolveAgainst(500, 1000)).to.be.false; // Start > 0 is fine, but range covers 0-500?
|
|
85
|
+
// wait, canResolveAgainst(currentStart, totalLength) checks if the range is valid for the current content?
|
|
86
|
+
// "range[0] < currentStart" is check.
|
|
87
|
+
// If currentStart is 500, range 0-499 is NOT valid
|
|
88
|
+
|
|
89
|
+
expect(range.canResolveAgainst(0, 400)).to.be.false; // range end > total
|
|
90
|
+
|
|
91
|
+
// Render
|
|
92
|
+
// range is [0, 499]
|
|
93
|
+
const rendered = range.resolveAgainst(1000);
|
|
94
|
+
expect(rendered).to.deep.equal([0, 499]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should handle open-ended ranges', function () {
|
|
98
|
+
const headers = new HeadersPlus({ 'Range': 'bytes=500-' });
|
|
99
|
+
const range = headers.get('Range', true)[0];
|
|
100
|
+
// [500, null]
|
|
101
|
+
|
|
102
|
+
const rendered = range.resolveAgainst(1000);
|
|
103
|
+
expect(rendered).to.deep.equal([500, 999]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should handle multiple Set-Cookie headers', function () {
|
|
107
|
+
const headers = new HeadersPlus();
|
|
108
|
+
headers.append('Set-Cookie', 'a=1; Path=/');
|
|
109
|
+
headers.append('Set-Cookie', 'b=2; Secure');
|
|
110
|
+
|
|
111
|
+
const cookies = headers.get('Set-Cookie', true);
|
|
112
|
+
expect(cookies).to.be.an('array').with.lengthOf(2);
|
|
113
|
+
expect(cookies[0]).to.deep.include({ name: 'a', value: '1' });
|
|
114
|
+
expect(cookies[1]).to.deep.include({ name: 'b', value: '2' });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should return 0 for matching unknown types in Accept header without wildcard', function () {
|
|
118
|
+
const headers = new HeadersPlus({
|
|
119
|
+
'Accept': 'text/html, application/json;q=0.9'
|
|
120
|
+
});
|
|
121
|
+
const accept = headers.get('Accept', true);
|
|
122
|
+
|
|
123
|
+
expect(accept.match('text/html')).to.equal(1);
|
|
124
|
+
expect(accept.match('image/png')).to.equal(0);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('RequestPlus & ResponsePlus', function () {
|
|
129
|
+
it('should support upgradeInPlace for Request', function () {
|
|
130
|
+
const req = new Request('http://example.com');
|
|
131
|
+
expect(req).to.not.be.instanceOf(RequestPlus);
|
|
132
|
+
|
|
133
|
+
RequestPlus.upgradeInPlace(req);
|
|
134
|
+
expect(req).to.be.instanceOf(RequestPlus);
|
|
135
|
+
|
|
136
|
+
expect(req.headers).to.be.instanceOf(Headers); // It is still headers
|
|
137
|
+
expect(req.headers).to.be.instanceOf(HeadersPlus); // It is now also headersPlus
|
|
138
|
+
|
|
139
|
+
req.headers.set('Range', 'bytes=0-100');
|
|
140
|
+
const range = req.headers.get('Range', true); // Should return array if upgraded
|
|
141
|
+
expect(range).to.be.an('array');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should support upgradeInPlace for Response', function () {
|
|
145
|
+
const res = new Response('body');
|
|
146
|
+
expect(res).to.not.be.instanceOf(ResponsePlus);
|
|
147
|
+
|
|
148
|
+
ResponsePlus.upgradeInPlace(res);
|
|
149
|
+
expect(res).to.be.instanceOf(ResponsePlus);
|
|
150
|
+
|
|
151
|
+
expect(res.headers).to.be.instanceOf(Headers); // It is still headers
|
|
152
|
+
expect(res.headers).to.be.instanceOf(HeadersPlus); // It is now also headersPlus
|
|
153
|
+
|
|
154
|
+
res.headers.set('Content-Range', 'bytes 0-100/1000');
|
|
155
|
+
const range = res.headers.get('Content-Range', true);
|
|
156
|
+
expect(range).to.deep.equal(['0-100', '1000']);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should create instances via .from()', function () {
|
|
160
|
+
const req = RequestPlus.from('http://example.com', { method: 'POST' });
|
|
161
|
+
expect(req).to.be.instanceOf(RequestPlus);
|
|
162
|
+
expect(req.method).to.equal('POST');
|
|
163
|
+
|
|
164
|
+
const res = ResponsePlus.from('content', { status: 201 });
|
|
165
|
+
expect(res).to.be.instanceOf(ResponsePlus);
|
|
166
|
+
expect(res.status).to.equal(201);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should provide extended body parsing (json)', async function () {
|
|
170
|
+
const data = { foo: 'bar' };
|
|
171
|
+
const res = new ResponsePlus(JSON.stringify(data), {
|
|
172
|
+
headers: { 'Content-Type': 'application/json' }
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const json = await res.json();
|
|
176
|
+
expect(json).to.deep.equal(data);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should support .any() with memoization and conversions', async function () {
|
|
180
|
+
const data = { foo: 'bar', prefs: [1, 2], loves_it: true };
|
|
181
|
+
|
|
182
|
+
// 1. Auto-detect json
|
|
183
|
+
const res1 = new ResponsePlus(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
|
184
|
+
const json = await res1.any();
|
|
185
|
+
expect(json).to.deep.equal(data);
|
|
186
|
+
|
|
187
|
+
// 2. Memoization (should return same object on subsequent calls)
|
|
188
|
+
const res2 = new ResponsePlus(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
|
189
|
+
const j1 = await res2.any({ memo: true }); // Returns original, caches clone
|
|
190
|
+
const j2 = await res2.any({ memo: true }); // Returns cached clone
|
|
191
|
+
const j3 = await res2.any({ memo: true }); // Returns cached clone
|
|
192
|
+
expect(j2).to.deep.equal(j1);
|
|
193
|
+
expect(j3).to.deep.equal(j2); // Non-strict equality for cached hits
|
|
194
|
+
|
|
195
|
+
// 3. Conversion JSON -> FormData
|
|
196
|
+
// If we ask for formData from a JSON response
|
|
197
|
+
const res3 = new ResponsePlus(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
|
198
|
+
const fd = await res3.any({ to: 'formData' });
|
|
199
|
+
// It should convert
|
|
200
|
+
expect(await fd.json()).to.deep.equal(data);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should auto-generate headers in .from()', async function () {
|
|
204
|
+
// 1. JSON
|
|
205
|
+
const res1 = ResponsePlus.from({ a: 1 });
|
|
206
|
+
// console.log('res1 headers:', [...res1.headers.entries()]);
|
|
207
|
+
expect(res1.headers.get('Content-Type')).to.be.a('string').that.includes('application/json');
|
|
208
|
+
expect(res1.headers.get('Content-Length')).to.exist;
|
|
209
|
+
|
|
210
|
+
// 2. String
|
|
211
|
+
const res2 = ResponsePlus.from('hello');
|
|
212
|
+
expect(res2.headers.get('Content-Length')).to.equal('5');
|
|
213
|
+
|
|
214
|
+
// 3. Binary (Uint8Array)
|
|
215
|
+
const buffer = new TextEncoder().encode('hello');
|
|
216
|
+
const res3 = ResponsePlus.from(buffer);
|
|
217
|
+
expect(res3.headers.get('Content-Length')).to.equal('5');
|
|
218
|
+
|
|
219
|
+
// 4. Blob
|
|
220
|
+
const blob = new Blob(['hello'], { type: 'text/plain' });
|
|
221
|
+
const res4 = ResponsePlus.from(blob);
|
|
222
|
+
expect(res4.headers.get('Content-Type')).to.equal('text/plain');
|
|
223
|
+
expect(res4.headers.get('Content-Length')).to.equal('5');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should auto-generate headers for specialized inputs in .from()', async function () {
|
|
227
|
+
const url = 'http://url';
|
|
228
|
+
|
|
229
|
+
// FormData
|
|
230
|
+
const req1 = RequestPlus.from(url, { body: new FormData(), method: 'POST' });
|
|
231
|
+
// Auto Content-Type: `multipart/form-data`
|
|
232
|
+
expect(req1.headers.get('Content-Type')).to.be.a('string').that.includes('multipart/form-data');
|
|
233
|
+
expect(req1.headers.get('Content-Length')).to.not.exist;
|
|
234
|
+
|
|
235
|
+
// JSON object with complex data types
|
|
236
|
+
const body2 = {
|
|
237
|
+
name: 'John Doe',
|
|
238
|
+
avatars: {
|
|
239
|
+
primary: new Blob(['imageBytes1'], { type: 'image/png' }),
|
|
240
|
+
secondary: new Blob(['imageBytes2'], { type: 'image/png' }),
|
|
241
|
+
},
|
|
242
|
+
loves_it: true,
|
|
243
|
+
};
|
|
244
|
+
const req2 = RequestPlus.from(url, { body: body2, method: 'POST' });
|
|
245
|
+
// Auto Content-Type: `multipart/form-data`
|
|
246
|
+
expect(req2.headers.get('Content-Type')).to.be.a('string').that.includes('multipart/form-data');
|
|
247
|
+
expect(req2.headers.get('Content-Length')).to.not.exist;
|
|
248
|
+
|
|
249
|
+
// TypeArray
|
|
250
|
+
const req3 = RequestPlus.from(url, { body: new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), method: 'POST' });
|
|
251
|
+
// Auto Content-Type: `application/octet-stream`
|
|
252
|
+
// Auto Content-Length: <number>
|
|
253
|
+
expect(req3.headers.get('Content-Type')).to.be.a('string').that.includes('application/octet-stream');
|
|
254
|
+
expect(req3.headers.get('Content-Length')).to.exist;
|
|
255
|
+
|
|
256
|
+
// Blob
|
|
257
|
+
const req4 = RequestPlus.from(url, { body: new Blob(['hello'], { type: 'text/plain' }), method: 'POST' });
|
|
258
|
+
// Auto Content-Type: `text/plain`
|
|
259
|
+
// Auto Content-Length: <number>
|
|
260
|
+
expect(req4.headers.get('Content-Type')).to.be.a('string').that.includes('text/plain');
|
|
261
|
+
expect(req4.headers.get('Content-Length')).to.exist;
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('FormDataPlus', function () {
|
|
266
|
+
it('should convert FormData to JSON', async function () {
|
|
267
|
+
const fd = new FormDataPlus();
|
|
268
|
+
fd.append('name', 'Alice');
|
|
269
|
+
fd.append('age', '30');
|
|
270
|
+
fd.append('skills[0]', 'JS');
|
|
271
|
+
fd.append('skills[]', 'Testing');
|
|
272
|
+
|
|
273
|
+
const json = await fd.json();
|
|
274
|
+
expect(json).to.deep.equal({
|
|
275
|
+
name: 'Alice',
|
|
276
|
+
age: 30,
|
|
277
|
+
skills: ['JS', 'Testing']
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Nested structure if supported?
|
|
282
|
+
it('should support nested structures in FormData keys', async function () {
|
|
283
|
+
const fd = new FormDataPlus();
|
|
284
|
+
fd.append('user[name]', 'Bob');
|
|
285
|
+
fd.append('user[address][city]', 'New York');
|
|
286
|
+
|
|
287
|
+
const json = await fd.json();
|
|
288
|
+
expect(json).to.deep.equal({
|
|
289
|
+
user: {
|
|
290
|
+
name: 'Bob',
|
|
291
|
+
address: {
|
|
292
|
+
city: 'New York'
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('fetchPlus', function () {
|
|
300
|
+
it('should return a ResponsePlus', async function () {
|
|
301
|
+
// Mock global fetch
|
|
302
|
+
const originalFetch = globalThis.fetch;
|
|
303
|
+
globalThis.fetch = async () => new Response('ok');
|
|
304
|
+
|
|
305
|
+
const res = await fetchPlus('http://mock.url');
|
|
306
|
+
// Check if it has extended capabilities, e.g. upgraded headers
|
|
307
|
+
res.headers.set('Range', 'bytes=0-10');
|
|
308
|
+
expect(res.headers.get('Range', true)).to.be.an('array');
|
|
309
|
+
|
|
310
|
+
globalThis.fetch = originalFetch;
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
});
|