-
-
Notifications
You must be signed in to change notification settings - Fork 93
/
RestEndpoint.js
278 lines (250 loc) · 7.79 KB
/
RestEndpoint.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
import { Endpoint } from '@data-client/endpoint';
import { pathToRegexp } from 'path-to-regexp';
import extractCollection from './extractCollection.js';
import mapCollection from './mapCollection.js';
import NetworkError from './NetworkError.js';
import { createPaginationSchema } from './paginatedCollections.js';
import paramsToString from './paramsToString.js';
import { getUrlBase, getUrlTokens, isPojo } from './RestHelpers.js';
/** Simplifies endpoint definitions that follow REST patterns
*
* @see https://dataclient.io/rest/api/RestEndpoint
*/
export default class RestEndpoint extends Endpoint {
#hasBody;
constructor(options) {
super(
options.fetch ??
async function (...args) {
const urlParams =
this.#hasBody && args.length < 2 ? {} : args[0] || {};
const body = this.#hasBody ? args[args.length - 1] : undefined;
return this.fetchResponse(
this.url(urlParams),
await this.getRequestInit(body),
)
.then(response => this.parseResponse(response))
.then(res => this.process(res, ...args));
},
options,
);
// we want to use the prototype chain here
if (
!('sideEffect' in this) ||
('method' in options && !('sideEffect' in options))
) {
this.sideEffect =
options.method === 'GET' || options.method === undefined ?
undefined
: true;
}
if (this.method === undefined) {
this.method = this.sideEffect ? 'POST' : 'GET';
}
if (this.urlPrefix === undefined) {
this.urlPrefix = '';
}
this.#hasBody =
(!('body' in this) || this.body !== undefined) &&
!['GET', 'DELETE'].includes(this.method);
Object.defineProperty(this, 'name', {
get() {
// using 'in' to ensure inheritance lookup
if ('__name' in this) return this.__name;
return this.urlPrefix + this.path;
},
});
}
key(...args) {
return `${this.method} ${this.url(
this.#hasBody && args.length < 2 ? {} : args[0] || {},
)}`;
}
/** Get the url */
url(urlParams = {}) {
const urlBase = getUrlBase(this.path)(urlParams);
const tokens = getUrlTokens(this.path);
const searchParams = {};
Object.keys(urlParams).forEach(k => {
if (!tokens.has(k)) {
searchParams[k] = urlParams[k];
}
});
if (Object.keys(searchParams).length) {
return `${this.urlPrefix}${urlBase}?${this.searchToString(searchParams)}`;
}
return `${this.urlPrefix}${urlBase}`;
}
/** Encode the url searchParams */
searchToString(searchParams) {
return paramsToString(searchParams);
}
getHeaders(headers) {
return headers;
}
/** Init options for fetch - run at fetch */
async getRequestInit(body) {
const bodyIsPojo = isPojo(body);
if (bodyIsPojo) {
body = JSON.stringify(body);
}
const init = {
...this.requestInit,
method: this.method,
signal: this.signal,
body,
};
if (!body || bodyIsPojo) {
init.headers = {
// default to application/json but allow user explicit overrides
'Content-Type': 'application/json',
...init.headers,
};
}
init.headers = await this.getHeaders(init.headers);
return init;
}
/** Perform network request and resolve with HTTP Response */
fetchResponse(input, init) {
return fetch(input, init)
.then(response => {
if (!response.ok) {
throw new NetworkError(response);
}
return response;
})
.catch(error => {
// ensure CORS, network down, and parse errors are still caught by NetworkErrorBoundary
if (error instanceof TypeError) {
error.status = 500;
}
throw error;
});
}
parseResponse(response) {
// this should not have any content to read
if (response.status === 204) return Promise.resolve(null);
if (!response.headers.get('content-type')?.includes('json')) {
return response.text().then(text => {
// string or 'not set' schema, are valid
// when overriding process they might handle other cases, so we don't want to block on our logic
if (
['string', 'undefined'].includes(typeof this.schema) ||
this.schema === null ||
this.process !== RestEndpoint.prototype.process
)
return text;
const error = new NetworkError(response);
error.status = 404;
error.message = `Unexpected text response for schema ${this.schema}`;
// custom dev-only messages for more detailed cause
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
if (
!(
response.headers.get('content-type')?.includes('html') ||
text.startsWith('<!doctype html>')
)
) {
if (tryParse(text) !== undefined) {
error.message = `"content-type" header does not include "json", but JSON response found.
See https://www.rfc-editor.org/rfc/rfc4627 for information on JSON responses
Using parsed JSON.
If text content was expected see https://dataclient.io/rest/api/RestEndpoint#parseResponse`;
}
} else {
error.message = `Unexpected html response for schema ${this.schema}
This likely means no API endpoint was configured for this request, resulting in an HTML fallback.
Response (first 300 characters): ${text.substring(0, 300)}`;
}
}
throw error;
});
}
return response.json().catch(error => {
error.status = 400;
throw error;
});
}
process(value) {
return value;
}
errorPolicy(error) {
return error.status >= 500 ? 'soft' : undefined;
}
get pathRegex() {
return pathToRegexp(this.path);
}
testKey(key) {
const prefix = this.method + ' ' + this.urlPrefix;
if (!key.startsWith(prefix)) return false;
let lastQuestion = key.lastIndexOf('?');
if (lastQuestion === -1) lastQuestion = undefined;
return this.pathRegex.test(key.substring(prefix.length, lastQuestion));
}
extend(options) {
// make a constructor/prototype based off this
// extend from it and init with options sent
class E extends this.constructor {}
Object.assign(E.prototype, this);
return new E(
// fetch get overridden by function prototype, so we must set it explicitly every time
{ fetch: this.fetch, ...options },
);
}
paginated(removeCursor) {
if (typeof removeCursor === 'string') {
const fieldName = removeCursor;
removeCursor = ({ ...params }) => {
delete params[fieldName];
return [params];
};
}
let found = false;
const createPaginatedSchema = collection => {
found = true;
return createPaginationSchema(removeCursor, collection);
};
const newSchema = mapCollection(this.schema, createPaginatedSchema);
if (!found) throw new Error('Missing Collection');
const sup = this;
return this.extend({
schema: newSchema,
key(...args) {
return sup.key.call(this, ...removeCursor(...args));
},
name: this.name + '.getPage',
});
}
get getPage() {
return this.paginated(this.paginationField);
}
get push() {
return this.extend({
method: 'POST',
schema: extractCollection(this.schema, s => s.push),
name: this.name + '.create',
});
}
get unshift() {
return this.extend({
method: 'POST',
schema: extractCollection(this.schema, s => s.unshift),
name: this.name + '.create',
});
}
get assign() {
return this.extend({
method: 'POST',
schema: extractCollection(this.schema, s => s.assign),
name: this.name + '.create',
});
}
}
const tryParse = input => {
try {
return JSON.parse(input);
} catch (e) {
return undefined;
}
};