@data-client/rest 0.1.0
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/LICENSE +201 -0
- package/README.md +110 -0
- package/dist/index.js +561 -0
- package/dist/index.umd.min.js +1 -0
- package/dist/next.js +520 -0
- package/dist/package.json +1 -0
- package/legacy/NetworkError.js +13 -0
- package/legacy/OptionsToFunction.js +2 -0
- package/legacy/RestEndpoint.js +225 -0
- package/legacy/RestEndpointTypeHelp.js +2 -0
- package/legacy/RestHelpers.js +33 -0
- package/legacy/createResource.js +88 -0
- package/legacy/extractCollection.js +14 -0
- package/legacy/getArrayPath.js +19 -0
- package/legacy/hookifyResource.js +24 -0
- package/legacy/index.js +10 -0
- package/legacy/mapCollection.js +14 -0
- package/legacy/next/OptionsToFunction.js +2 -0
- package/legacy/next/RestEndpoint.js +225 -0
- package/legacy/next/createResource.js +86 -0
- package/legacy/next/index.js +4 -0
- package/legacy/next/resourceTypes.js +2 -0
- package/legacy/paginatedCollections.js +29 -0
- package/legacy/paginationUpdate.js +36 -0
- package/legacy/paramsToString.js +6 -0
- package/legacy/paramsToString.native.js +9 -0
- package/legacy/pathTypes.js +2 -0
- package/legacy/test-fixtures.js +111 -0
- package/legacy/utiltypes.js +2 -0
- package/lib/NetworkError.d.ts +11 -0
- package/lib/NetworkError.d.ts.map +1 -0
- package/lib/NetworkError.js +13 -0
- package/lib/OptionsToFunction.d.ts +7 -0
- package/lib/OptionsToFunction.d.ts.map +1 -0
- package/lib/OptionsToFunction.js +2 -0
- package/lib/RestEndpoint.d.ts +548 -0
- package/lib/RestEndpoint.js +229 -0
- package/lib/RestEndpointTypeHelp.d.ts +10 -0
- package/lib/RestEndpointTypeHelp.d.ts.map +1 -0
- package/lib/RestEndpointTypeHelp.js +2 -0
- package/lib/RestHelpers.d.ts +7 -0
- package/lib/RestHelpers.d.ts.map +1 -0
- package/lib/RestHelpers.js +33 -0
- package/lib/createResource.d.ts +66 -0
- package/lib/createResource.d.ts.map +1 -0
- package/lib/createResource.js +104 -0
- package/lib/extractCollection.d.ts +11 -0
- package/lib/extractCollection.d.ts.map +1 -0
- package/lib/extractCollection.js +16 -0
- package/lib/getArrayPath.d.ts +3 -0
- package/lib/getArrayPath.d.ts.map +1 -0
- package/lib/getArrayPath.js +19 -0
- package/lib/hookifyResource.d.ts +14 -0
- package/lib/hookifyResource.d.ts.map +1 -0
- package/lib/hookifyResource.js +24 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +10 -0
- package/lib/mapCollection.d.ts +16 -0
- package/lib/mapCollection.d.ts.map +1 -0
- package/lib/mapCollection.js +16 -0
- package/lib/next/OptionsToFunction.d.ts +7 -0
- package/lib/next/OptionsToFunction.d.ts.map +1 -0
- package/lib/next/OptionsToFunction.js +2 -0
- package/lib/next/RestEndpoint.d.ts +542 -0
- package/lib/next/RestEndpoint.js +229 -0
- package/lib/next/createResource.d.ts +86 -0
- package/lib/next/createResource.d.ts.map +1 -0
- package/lib/next/createResource.js +100 -0
- package/lib/next/index.d.ts +6 -0
- package/lib/next/index.d.ts.map +1 -0
- package/lib/next/index.js +4 -0
- package/lib/next/resourceTypes.d.ts +31 -0
- package/lib/next/resourceTypes.d.ts.map +1 -0
- package/lib/next/resourceTypes.js +2 -0
- package/lib/paginatedCollections.d.ts +3 -0
- package/lib/paginatedCollections.d.ts.map +1 -0
- package/lib/paginatedCollections.js +29 -0
- package/lib/paginationUpdate.d.ts +8 -0
- package/lib/paginationUpdate.d.ts.map +1 -0
- package/lib/paginationUpdate.js +40 -0
- package/lib/paramsToString.d.ts +2 -0
- package/lib/paramsToString.d.ts.map +1 -0
- package/lib/paramsToString.js +6 -0
- package/lib/paramsToString.native.d.ts +2 -0
- package/lib/paramsToString.native.d.ts.map +1 -0
- package/lib/paramsToString.native.js +9 -0
- package/lib/pathTypes.d.ts +20 -0
- package/lib/pathTypes.d.ts.map +1 -0
- package/lib/pathTypes.js +2 -0
- package/lib/test-fixtures.d.ts +141 -0
- package/lib/test-fixtures.d.ts.map +1 -0
- package/lib/test-fixtures.js +111 -0
- package/lib/utiltypes.d.ts +21 -0
- package/lib/utiltypes.d.ts.map +1 -0
- package/lib/utiltypes.js +2 -0
- package/node.mjs +1 -0
- package/package.json +121 -0
- package/src/NetworkError.ts +17 -0
- package/src/OptionsToFunction.ts +42 -0
- package/src/RestEndpoint.d.ts +548 -0
- package/src/RestEndpoint.js +262 -0
- package/src/RestEndpointTypeHelp.ts +20 -0
- package/src/RestHelpers.ts +48 -0
- package/src/__tests__/Resource.test.ts +317 -0
- package/src/__tests__/RestEndpoint.ts +1304 -0
- package/src/__tests__/__snapshots__/RestEndpoint.ts.snap +18 -0
- package/src/__tests__/__snapshots__/createResource.test.ts.snap +5 -0
- package/src/__tests__/createResource.test.ts +583 -0
- package/src/__tests__/getArrayPath.test.ts +30 -0
- package/src/__tests__/hookifyResource.ts +197 -0
- package/src/__tests__/types.test.ts +309 -0
- package/src/createResource.ts +172 -0
- package/src/extractCollection.ts +34 -0
- package/src/getArrayPath.ts +20 -0
- package/src/hookifyResource.ts +36 -0
- package/src/index.ts +42 -0
- package/src/mapCollection.ts +54 -0
- package/src/next/OptionsToFunction.ts +41 -0
- package/src/next/RestEndpoint.d.ts +542 -0
- package/src/next/RestEndpoint.js +262 -0
- package/src/next/__tests__/RestEndpoint.ts +1333 -0
- package/src/next/__tests__/__snapshots__/RestEndpoint.ts.snap +18 -0
- package/src/next/__tests__/types.test.ts +429 -0
- package/src/next/createResource.ts +194 -0
- package/src/next/index.ts +5 -0
- package/src/next/resourceTypes.ts +32 -0
- package/src/package.json +1 -0
- package/src/paginatedCollections.ts +32 -0
- package/src/paginationUpdate.ts +49 -0
- package/src/paramsToString.native.ts +10 -0
- package/src/paramsToString.ts +7 -0
- package/src/pathTypes.ts +58 -0
- package/src/test-fixtures.ts +128 -0
- package/src/utiltypes.ts +25 -0
- package/ts4.0/NetworkError.d.ts +11 -0
- package/ts4.0/OptionsToFunction.d.ts +16 -0
- package/ts4.0/RestEndpoint.d.ts +240 -0
- package/ts4.0/RestEndpointTypeHelp.d.ts +10 -0
- package/ts4.0/RestHelpers.d.ts +7 -0
- package/ts4.0/createResource.d.ts +66 -0
- package/ts4.0/extractCollection.d.ts +11 -0
- package/ts4.0/getArrayPath.d.ts +3 -0
- package/ts4.0/hookifyResource.d.ts +19 -0
- package/ts4.0/index.d.ts +12 -0
- package/ts4.0/mapCollection.d.ts +16 -0
- package/ts4.0/next/OptionsToFunction.d.ts +16 -0
- package/ts4.0/next/RestEndpoint.d.ts +226 -0
- package/ts4.0/next/createResource.d.ts +94 -0
- package/ts4.0/next/index.d.ts +6 -0
- package/ts4.0/next/resourceTypes.d.ts +31 -0
- package/ts4.0/paginatedCollections.d.ts +3 -0
- package/ts4.0/paginationUpdate.d.ts +10 -0
- package/ts4.0/paramsToString.d.ts +2 -0
- package/ts4.0/paramsToString.native.d.ts +2 -0
- package/ts4.0/pathTypes.d.ts +20 -0
- package/ts4.0/test-fixtures.d.ts +141 -0
- package/ts4.0/utiltypes.d.ts +5 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { Endpoint } from '@data-client/endpoint';
|
|
2
|
+
import { pathToRegexp } from 'path-to-regexp';
|
|
3
|
+
|
|
4
|
+
import extractCollection from './extractCollection.js';
|
|
5
|
+
import mapCollection from './mapCollection.js';
|
|
6
|
+
import NetworkError from './NetworkError.js';
|
|
7
|
+
import { paginatedMerge, paginatedFilter } from './paginatedCollections.js';
|
|
8
|
+
import paginationUpdate from './paginationUpdate.js';
|
|
9
|
+
import paramsToString from './paramsToString.js';
|
|
10
|
+
import { getUrlBase, getUrlTokens, isPojo } from './RestHelpers.js';
|
|
11
|
+
|
|
12
|
+
/** Simplifies endpoint definitions that follow REST patterns
|
|
13
|
+
*
|
|
14
|
+
* @see https://resthooks.io/rest/api/RestEndpoint
|
|
15
|
+
*/
|
|
16
|
+
export default class RestEndpoint extends Endpoint {
|
|
17
|
+
#hasBody;
|
|
18
|
+
constructor(options) {
|
|
19
|
+
super(
|
|
20
|
+
options.fetch ??
|
|
21
|
+
function (...args) {
|
|
22
|
+
const urlParams =
|
|
23
|
+
this.#hasBody && args.length < 2 ? {} : args[0] || {};
|
|
24
|
+
const body = this.#hasBody ? args[args.length - 1] : undefined;
|
|
25
|
+
return this.fetchResponse(
|
|
26
|
+
this.url(urlParams),
|
|
27
|
+
this.getRequestInit(body),
|
|
28
|
+
)
|
|
29
|
+
.then(response => this.parseResponse(response))
|
|
30
|
+
.then(res => this.process(res, ...args));
|
|
31
|
+
},
|
|
32
|
+
options,
|
|
33
|
+
);
|
|
34
|
+
// we want to use the prototype chain here
|
|
35
|
+
if (
|
|
36
|
+
!('sideEffect' in this) ||
|
|
37
|
+
('method' in options && !('sideEffect' in options))
|
|
38
|
+
) {
|
|
39
|
+
this.sideEffect =
|
|
40
|
+
options.method === 'GET' || options.method === undefined
|
|
41
|
+
? undefined
|
|
42
|
+
: true;
|
|
43
|
+
}
|
|
44
|
+
if (this.method === undefined) {
|
|
45
|
+
this.method = this.sideEffect ? 'POST' : 'GET';
|
|
46
|
+
}
|
|
47
|
+
if (this.urlPrefix === undefined) {
|
|
48
|
+
this.urlPrefix = '';
|
|
49
|
+
}
|
|
50
|
+
this.#hasBody =
|
|
51
|
+
(!('body' in this) || this.body !== undefined) &&
|
|
52
|
+
!['GET', 'DELETE'].includes(this.method);
|
|
53
|
+
|
|
54
|
+
Object.defineProperty(this, 'name', {
|
|
55
|
+
get() {
|
|
56
|
+
// using 'in' to ensure inheritance lookup
|
|
57
|
+
if ('__name' in this) return this.__name;
|
|
58
|
+
return this.urlPrefix + this.path;
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
key(...args) {
|
|
64
|
+
return `${this.method} ${this.url(
|
|
65
|
+
this.#hasBody && args.length < 2 ? {} : args[0] || {},
|
|
66
|
+
)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Get the url */
|
|
70
|
+
url(urlParams = {}) {
|
|
71
|
+
const urlBase = getUrlBase(this.path)(urlParams);
|
|
72
|
+
const tokens = getUrlTokens(this.path);
|
|
73
|
+
const searchParams = {};
|
|
74
|
+
Object.keys(urlParams).forEach(k => {
|
|
75
|
+
if (!tokens.has(k)) {
|
|
76
|
+
searchParams[k] = urlParams[k];
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
if (Object.keys(searchParams).length) {
|
|
80
|
+
return `${this.urlPrefix}${urlBase}?${paramsToString(searchParams)}`;
|
|
81
|
+
}
|
|
82
|
+
return `${this.urlPrefix}${urlBase}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getHeaders(headers) {
|
|
86
|
+
return headers;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Init options for fetch - run at fetch */
|
|
90
|
+
getRequestInit(body) {
|
|
91
|
+
const bodyIsPojo = isPojo(body);
|
|
92
|
+
if (bodyIsPojo) {
|
|
93
|
+
body = JSON.stringify(body);
|
|
94
|
+
}
|
|
95
|
+
const init = {
|
|
96
|
+
...this.requestInit,
|
|
97
|
+
method: this.method,
|
|
98
|
+
signal: this.signal,
|
|
99
|
+
body,
|
|
100
|
+
};
|
|
101
|
+
if (!body || bodyIsPojo) {
|
|
102
|
+
init.headers = {
|
|
103
|
+
// default to application/json but allow user explicit overrides
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
...init.headers,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
init.headers = this.getHeaders(init.headers);
|
|
109
|
+
return init;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Perform network request and resolve with HTTP Response */
|
|
113
|
+
fetchResponse(input, init) {
|
|
114
|
+
return fetch(input, init)
|
|
115
|
+
.then(response => {
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
throw new NetworkError(response);
|
|
118
|
+
}
|
|
119
|
+
return response;
|
|
120
|
+
})
|
|
121
|
+
.catch(error => {
|
|
122
|
+
// ensure CORS, network down, and parse errors are still caught by NetworkErrorBoundary
|
|
123
|
+
if (error instanceof TypeError) {
|
|
124
|
+
error.status = 500;
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
parseResponse(response) {
|
|
131
|
+
// this should not have any content to read
|
|
132
|
+
if (response.status === 204) return Promise.resolve(null);
|
|
133
|
+
if (!response.headers.get('content-type')?.includes('json')) {
|
|
134
|
+
return response.text().then(text => {
|
|
135
|
+
// string or 'not set' schema, are valid
|
|
136
|
+
// when overriding process they might handle other cases, so we don't want to block on our logic
|
|
137
|
+
if (
|
|
138
|
+
['string', 'undefined'].includes(typeof this.schema) ||
|
|
139
|
+
this.schema === null ||
|
|
140
|
+
this.process !== RestEndpoint.prototype.process
|
|
141
|
+
)
|
|
142
|
+
return text;
|
|
143
|
+
|
|
144
|
+
const error = new NetworkError(response);
|
|
145
|
+
error.status = 404;
|
|
146
|
+
error.message = `Unexpected text response for schema ${this.schema}`;
|
|
147
|
+
// custom dev-only messages for more detailed cause
|
|
148
|
+
/* istanbul ignore else */
|
|
149
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
150
|
+
if (
|
|
151
|
+
!(
|
|
152
|
+
response.headers.get('content-type')?.includes('html') ||
|
|
153
|
+
text.startsWith('<!doctype html>')
|
|
154
|
+
)
|
|
155
|
+
) {
|
|
156
|
+
if (tryParse(text) !== undefined) {
|
|
157
|
+
error.message = `"content-type" header does not include "json", but JSON response found.
|
|
158
|
+
See https://www.rfc-editor.org/rfc/rfc4627 for information on JSON responses
|
|
159
|
+
|
|
160
|
+
Using parsed JSON.
|
|
161
|
+
If text content was expected see https://resthooks.io/rest/api/RestEndpoint#parseResponse`;
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
error.message = `Unexpected html response for schema ${this.schema}
|
|
165
|
+
This likely means no API endpoint was configured for this request, resulting in an HTML fallback.
|
|
166
|
+
|
|
167
|
+
Response (first 300 characters): ${text.substring(0, 300)}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
throw error;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return response.json().catch(error => {
|
|
174
|
+
error.status = 400;
|
|
175
|
+
throw error;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
process(value) {
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
errorPolicy(error) {
|
|
184
|
+
return error.status >= 500 ? 'soft' : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
get pathRegex() {
|
|
188
|
+
return pathToRegexp(this.path);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
testKey(key) {
|
|
192
|
+
const prefix = this.method + ' ' + this.urlPrefix;
|
|
193
|
+
if (!key.startsWith(prefix)) return false;
|
|
194
|
+
let lastQuestion = key.lastIndexOf('?');
|
|
195
|
+
if (lastQuestion === -1) lastQuestion = undefined;
|
|
196
|
+
return this.pathRegex.test(key.substring(prefix.length, lastQuestion));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
extend(options) {
|
|
200
|
+
// make a constructor/prototype based off this
|
|
201
|
+
// extend from it and init with options sent
|
|
202
|
+
class E extends this.constructor {}
|
|
203
|
+
|
|
204
|
+
Object.assign(E.prototype, this);
|
|
205
|
+
const instance = new E(
|
|
206
|
+
// fetch get overridden by function prototype, so we must set it explicitly every time
|
|
207
|
+
{ fetch: this.fetch, ...options },
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
return instance;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
paginated(removeCursor) {
|
|
214
|
+
if (typeof removeCursor === 'string') {
|
|
215
|
+
const fieldName = removeCursor;
|
|
216
|
+
removeCursor = ({ ...params }) => {
|
|
217
|
+
delete params[fieldName];
|
|
218
|
+
return [params];
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
let found = false;
|
|
222
|
+
const createPaginatedSchema = collection => {
|
|
223
|
+
found = true;
|
|
224
|
+
return collection.addWith(paginatedMerge, paginatedFilter(removeCursor));
|
|
225
|
+
};
|
|
226
|
+
const newSchema = mapCollection(this.schema, createPaginatedSchema);
|
|
227
|
+
if (found) {
|
|
228
|
+
return this.extend({ schema: newSchema });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return this.extend({ update: paginationUpdate(this, removeCursor) });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
get push() {
|
|
235
|
+
return this.extend({
|
|
236
|
+
method: 'POST',
|
|
237
|
+
schema: extractCollection(this.schema, s => s.push),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
get unshift() {
|
|
242
|
+
return this.extend({
|
|
243
|
+
method: 'POST',
|
|
244
|
+
schema: extractCollection(this.schema, s => s.unshift),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
get assign() {
|
|
249
|
+
return this.extend({
|
|
250
|
+
method: 'POST',
|
|
251
|
+
schema: extractCollection(this.schema, s => s.assign),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const tryParse = input => {
|
|
257
|
+
try {
|
|
258
|
+
return JSON.parse(input);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FetchFunction,
|
|
3
|
+
ResolveType,
|
|
4
|
+
Normalize,
|
|
5
|
+
} from '@data-client/endpoint';
|
|
6
|
+
|
|
7
|
+
export type EndpointUpdateFunction<
|
|
8
|
+
Source extends FetchFunction,
|
|
9
|
+
Schema,
|
|
10
|
+
Updaters extends Record<string, any> = Record<string, any>,
|
|
11
|
+
> = (
|
|
12
|
+
source: ResultEntry<Source & { schema: Schema }>,
|
|
13
|
+
...args: Parameters<Source>
|
|
14
|
+
) => {
|
|
15
|
+
[K in keyof Updaters]: (result: Updaters[K]) => Updaters[K];
|
|
16
|
+
};
|
|
17
|
+
export type ResultEntry<E extends FetchFunction & { schema: any }> =
|
|
18
|
+
E['schema'] extends undefined | null
|
|
19
|
+
? ResolveType<E>
|
|
20
|
+
: Normalize<E['schema']>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { compile, PathFunction, parse } from 'path-to-regexp';
|
|
2
|
+
|
|
3
|
+
import { ShortenPath } from './pathTypes.js';
|
|
4
|
+
|
|
5
|
+
const urlBaseCache: Record<string, PathFunction<object>> = Object.create(null);
|
|
6
|
+
export function getUrlBase(path: string): PathFunction {
|
|
7
|
+
if (!(path in urlBaseCache)) {
|
|
8
|
+
urlBaseCache[path] = compile(path, {
|
|
9
|
+
encode: encodeURIComponent,
|
|
10
|
+
validate: false,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
return urlBaseCache[path];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const urlTokensCache: Record<string, Set<string>> = Object.create(null);
|
|
17
|
+
export function getUrlTokens(path: string): Set<string> {
|
|
18
|
+
if (!(path in urlTokensCache)) {
|
|
19
|
+
urlTokensCache[path] = new Set(
|
|
20
|
+
parse(path).map(t => (typeof t === 'string' ? t : `${t['name']}`)),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return urlTokensCache[path];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const proto = Object.prototype;
|
|
27
|
+
const gpo = Object.getPrototypeOf;
|
|
28
|
+
|
|
29
|
+
export function isPojo(obj: unknown): obj is Record<string, any> {
|
|
30
|
+
if (obj === null || typeof obj !== 'object') {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return gpo(obj) === proto;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function shortenPath<S extends string>(path: S): ShortenPath<S> {
|
|
37
|
+
// this is for when not specifying a specific item like create/list
|
|
38
|
+
let shortUrlRoot: ShortenPath<S> = path.substring(
|
|
39
|
+
0,
|
|
40
|
+
path.lastIndexOf(':'),
|
|
41
|
+
) as any;
|
|
42
|
+
if (shortUrlRoot[shortUrlRoot.length - 1] === '/')
|
|
43
|
+
shortUrlRoot = shortUrlRoot.substring(
|
|
44
|
+
0,
|
|
45
|
+
shortUrlRoot.length - 1,
|
|
46
|
+
) as ShortenPath<S>;
|
|
47
|
+
return shortUrlRoot;
|
|
48
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { Entity, Schema } from '@data-client/endpoint';
|
|
2
|
+
import { useController } from '@data-client/react';
|
|
3
|
+
import { useSuspense } from '@data-client/react';
|
|
4
|
+
import { CacheProvider } from '@data-client/react';
|
|
5
|
+
import { act } from '@testing-library/react-hooks';
|
|
6
|
+
import nock from 'nock';
|
|
7
|
+
|
|
8
|
+
import { makeRenderRestHook } from '../../../test';
|
|
9
|
+
import createResource from '../createResource';
|
|
10
|
+
import RestEndpoint from '../RestEndpoint';
|
|
11
|
+
import {
|
|
12
|
+
payload,
|
|
13
|
+
createPayload,
|
|
14
|
+
users,
|
|
15
|
+
nested,
|
|
16
|
+
moreNested,
|
|
17
|
+
paginatedFirstPage,
|
|
18
|
+
paginatedSecondPage,
|
|
19
|
+
} from '../test-fixtures';
|
|
20
|
+
|
|
21
|
+
export class User extends Entity {
|
|
22
|
+
readonly id: number | undefined = undefined;
|
|
23
|
+
readonly username: string = '';
|
|
24
|
+
readonly email: string = '';
|
|
25
|
+
readonly isAdmin: boolean = false;
|
|
26
|
+
|
|
27
|
+
pk() {
|
|
28
|
+
return this.id?.toString();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export const UserResource = createResource({
|
|
32
|
+
path: 'http\\://test.com/user/:id',
|
|
33
|
+
schema: User,
|
|
34
|
+
});
|
|
35
|
+
export class PaginatedArticle extends Entity {
|
|
36
|
+
readonly id: number | undefined = undefined;
|
|
37
|
+
readonly title: string = '';
|
|
38
|
+
readonly content: string = '';
|
|
39
|
+
readonly author: number | null = null;
|
|
40
|
+
readonly tags: string[] = [];
|
|
41
|
+
|
|
42
|
+
pk() {
|
|
43
|
+
return this.id?.toString();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static schema = {
|
|
47
|
+
author: User,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function createPaginatableResource<U extends string, S extends Schema>({
|
|
51
|
+
path,
|
|
52
|
+
schema,
|
|
53
|
+
Endpoint = RestEndpoint,
|
|
54
|
+
}: {
|
|
55
|
+
readonly path: U;
|
|
56
|
+
readonly schema: S;
|
|
57
|
+
readonly Endpoint?: typeof RestEndpoint;
|
|
58
|
+
}) {
|
|
59
|
+
const baseResource = createResource({ path, schema, Endpoint });
|
|
60
|
+
const getList = baseResource.getList.extend({
|
|
61
|
+
path: 'http\\://test.com/article-paginated',
|
|
62
|
+
schema: {
|
|
63
|
+
nextPage: '',
|
|
64
|
+
data: { results: [PaginatedArticle] },
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const getNextPage = getList.paginated((v: { cursor: string | number }) => []);
|
|
68
|
+
return {
|
|
69
|
+
...baseResource,
|
|
70
|
+
getList,
|
|
71
|
+
getNextPage,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const PaginatedArticleResource = createPaginatableResource({
|
|
75
|
+
path: 'http\\://test.com/article-paginated/:id',
|
|
76
|
+
schema: PaginatedArticle,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export class UrlArticle extends PaginatedArticle {
|
|
80
|
+
readonly url: string = 'happy.com';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('createResource()', () => {
|
|
84
|
+
const renderRestHook: ReturnType<typeof makeRenderRestHook> =
|
|
85
|
+
makeRenderRestHook(CacheProvider);
|
|
86
|
+
let mynock: nock.Scope;
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
nock(/.*/)
|
|
90
|
+
.persist()
|
|
91
|
+
.defaultReplyHeaders({
|
|
92
|
+
'Access-Control-Allow-Origin': '*',
|
|
93
|
+
'Content-Type': 'application/json',
|
|
94
|
+
})
|
|
95
|
+
.options(/.*/)
|
|
96
|
+
.reply(200)
|
|
97
|
+
.get(`/article-cooler/${payload.id}`)
|
|
98
|
+
.reply(200, payload)
|
|
99
|
+
.delete(`/article-cooler/${payload.id}`)
|
|
100
|
+
.reply(204, '')
|
|
101
|
+
.delete(`/article/${payload.id}`)
|
|
102
|
+
.reply(200, {})
|
|
103
|
+
.get(`/article-cooler/0`)
|
|
104
|
+
.reply(403, {})
|
|
105
|
+
.get(`/article-cooler/666`)
|
|
106
|
+
.reply(200, '')
|
|
107
|
+
.get(`/article-cooler`)
|
|
108
|
+
.reply(200, nested)
|
|
109
|
+
.post(`/article-cooler`)
|
|
110
|
+
.reply(200, createPayload)
|
|
111
|
+
.get(`/user`)
|
|
112
|
+
.reply(200, users);
|
|
113
|
+
mynock = nock(/.*/).defaultReplyHeaders({
|
|
114
|
+
'Access-Control-Allow-Origin': '*',
|
|
115
|
+
'Content-Type': 'application/json',
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
nock.cleanAll();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should handle simple urls', () => {
|
|
124
|
+
expect(UserResource.get.url({ id: '5' })).toBe('http://test.com/user/5');
|
|
125
|
+
expect(UserResource.get.url({ id: '100' })).toBe(
|
|
126
|
+
'http://test.com/user/100',
|
|
127
|
+
);
|
|
128
|
+
/*expect(UserResource.getList.url({ bob: '100' })).toBe(
|
|
129
|
+
'http://test.com/user?bob=100',
|
|
130
|
+
);
|
|
131
|
+
expect(UserResource.create.url({ bob: '100' })).toBe(
|
|
132
|
+
'http://test.com/user',
|
|
133
|
+
);*/
|
|
134
|
+
expect(
|
|
135
|
+
UserResource.update.url({ id: '100' }, { id: 100, username: 'bob' }),
|
|
136
|
+
).toBe('http://test.com/user/100');
|
|
137
|
+
|
|
138
|
+
// @ts-expect-error
|
|
139
|
+
() => UserResource.get.url({ sdf: '5' });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should handle multiarg urls', () => {
|
|
143
|
+
const MyUserResource = createResource({
|
|
144
|
+
path: 'http\\://test.com/groups/:group/users/:id',
|
|
145
|
+
schema: User,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(MyUserResource.get.url({ group: 'big', id: '5' })).toBe(
|
|
149
|
+
'http://test.com/groups/big/users/5',
|
|
150
|
+
);
|
|
151
|
+
expect(MyUserResource.get.url({ group: 'big', id: '100' })).toBe(
|
|
152
|
+
'http://test.com/groups/big/users/100',
|
|
153
|
+
);
|
|
154
|
+
/*expect(MyUserResource.getList.url({ group: 'big', bob: '100' })).toBe(
|
|
155
|
+
'http://test.com/groups/big/users?bob=100',
|
|
156
|
+
);*/
|
|
157
|
+
expect(
|
|
158
|
+
MyUserResource.create.url({ group: 'big' }, { username: '100' }),
|
|
159
|
+
).toBe('http://test.com/groups/big/users');
|
|
160
|
+
expect(
|
|
161
|
+
MyUserResource.update.url(
|
|
162
|
+
{ group: 'big', id: '100' },
|
|
163
|
+
{ id: 100, username: 'bob' },
|
|
164
|
+
),
|
|
165
|
+
).toBe('http://test.com/groups/big/users/100');
|
|
166
|
+
|
|
167
|
+
// missing required
|
|
168
|
+
expect(() =>
|
|
169
|
+
// @ts-expect-error
|
|
170
|
+
MyUserResource.get.url({ id: '5' }),
|
|
171
|
+
).toThrow();
|
|
172
|
+
// extra fields
|
|
173
|
+
() =>
|
|
174
|
+
MyUserResource.get.url({
|
|
175
|
+
group: 'mygroup',
|
|
176
|
+
id: '5',
|
|
177
|
+
// @ts-expect-error
|
|
178
|
+
notexisting: 'hi',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// @ts-expect-error
|
|
182
|
+
() => useSuspense(MyUserResource.get, { id: '5' });
|
|
183
|
+
// @ts-expect-error
|
|
184
|
+
() => useSuspense(MyUserResource.get);
|
|
185
|
+
() => useSuspense(MyUserResource.get, { group: 'yay', id: '5' });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should automatically name methods', () => {
|
|
189
|
+
expect(PaginatedArticleResource.get.name).toBe('PaginatedArticle.get');
|
|
190
|
+
expect(PaginatedArticleResource.create.name).toBe(
|
|
191
|
+
'PaginatedArticle.create',
|
|
192
|
+
);
|
|
193
|
+
expect(PaginatedArticleResource.getList.name).toBe(
|
|
194
|
+
'PaginatedArticle.getList',
|
|
195
|
+
);
|
|
196
|
+
expect(PaginatedArticleResource.delete.name).toBe(
|
|
197
|
+
'PaginatedArticle.delete',
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should update on get for a paginated resource', async () => {
|
|
202
|
+
mynock.get(`/article-paginated`).reply(200, paginatedFirstPage);
|
|
203
|
+
mynock.get(`/article-paginated?cursor=2`).reply(200, paginatedSecondPage);
|
|
204
|
+
|
|
205
|
+
const { result, waitForNextUpdate } = renderRestHook(() => {
|
|
206
|
+
const { fetch } = useController();
|
|
207
|
+
const {
|
|
208
|
+
data: { results: articles },
|
|
209
|
+
nextPage,
|
|
210
|
+
} = useSuspense(PaginatedArticleResource.getList);
|
|
211
|
+
return { articles, nextPage, fetch };
|
|
212
|
+
});
|
|
213
|
+
await waitForNextUpdate();
|
|
214
|
+
() =>
|
|
215
|
+
// @ts-expect-error
|
|
216
|
+
result.current.fetch(PaginatedArticleResource.getNextPage);
|
|
217
|
+
await result.current.fetch(PaginatedArticleResource.getNextPage, {
|
|
218
|
+
cursor: 2,
|
|
219
|
+
});
|
|
220
|
+
expect(result.current.articles.map(({ id }) => id)).toEqual([5, 3, 7, 8]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should deduplicate results', async () => {
|
|
224
|
+
mynock.get(`/article-paginated`).reply(200, paginatedFirstPage);
|
|
225
|
+
mynock.get(`/article-paginated?cursor=2`).reply(200, {
|
|
226
|
+
...paginatedSecondPage,
|
|
227
|
+
results: [nested[nested.length - 1], ...moreNested],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const { result, waitForNextUpdate } = renderRestHook(() => {
|
|
231
|
+
const { fetch } = useController();
|
|
232
|
+
const {
|
|
233
|
+
data: { results: articles },
|
|
234
|
+
nextPage,
|
|
235
|
+
} = useSuspense(PaginatedArticleResource.getList);
|
|
236
|
+
return { articles, nextPage, fetch };
|
|
237
|
+
});
|
|
238
|
+
await waitForNextUpdate();
|
|
239
|
+
await result.current.fetch(PaginatedArticleResource.getNextPage, {
|
|
240
|
+
cursor: 2,
|
|
241
|
+
});
|
|
242
|
+
expect(result.current.articles.map(({ id }) => id)).toEqual([5, 3, 7, 8]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should not deep-merge deeply defined entities', async () => {
|
|
246
|
+
interface Complex {
|
|
247
|
+
firstvalue: number;
|
|
248
|
+
secondthing: {
|
|
249
|
+
arg?: number;
|
|
250
|
+
other?: string;
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
class ComplexEntity extends Entity {
|
|
254
|
+
readonly id: string = '';
|
|
255
|
+
readonly complexThing?: Complex = undefined;
|
|
256
|
+
readonly extra: string = '';
|
|
257
|
+
|
|
258
|
+
pk() {
|
|
259
|
+
return this.id;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const ComplexResource = createResource({
|
|
263
|
+
path: '/complex-thing/:id',
|
|
264
|
+
schema: ComplexEntity,
|
|
265
|
+
});
|
|
266
|
+
const firstResponse = {
|
|
267
|
+
id: '5',
|
|
268
|
+
complexThing: {
|
|
269
|
+
firstvalue: 233,
|
|
270
|
+
secondthing: { arg: 88 },
|
|
271
|
+
},
|
|
272
|
+
extra: 'hi',
|
|
273
|
+
};
|
|
274
|
+
mynock.get(`/complex-thing/5`).reply(200, firstResponse);
|
|
275
|
+
|
|
276
|
+
const { result, waitForNextUpdate } = renderRestHook(() => {
|
|
277
|
+
const { fetch } = useController();
|
|
278
|
+
const article = useSuspense(ComplexResource.get, { id: '5' });
|
|
279
|
+
return { article, fetch };
|
|
280
|
+
});
|
|
281
|
+
await waitForNextUpdate();
|
|
282
|
+
expect(result.current.article).toEqual(firstResponse);
|
|
283
|
+
|
|
284
|
+
const secondResponse = {
|
|
285
|
+
id: '5',
|
|
286
|
+
complexThing: {
|
|
287
|
+
firstvalue: 5,
|
|
288
|
+
secondthing: { other: 'hi' },
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
mynock.get(`/complex-thing/5`).reply(200, secondResponse);
|
|
293
|
+
await result.current.fetch(ComplexResource.get, {
|
|
294
|
+
id: '5',
|
|
295
|
+
});
|
|
296
|
+
expect(result.current.article).toEqual({ ...secondResponse, extra: 'hi' });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('delete() should fallback to params when response is empty object', async () => {
|
|
300
|
+
mynock.delete(`/article-paginated/500`).reply(200, {});
|
|
301
|
+
const res = await PaginatedArticleResource.delete({ id: 500 });
|
|
302
|
+
expect(res).toEqual({ id: 500 });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('delete() should fallback to params when response is undefined', async () => {
|
|
306
|
+
mynock.delete(`/article-paginated/500`).reply(204, undefined);
|
|
307
|
+
const res = await PaginatedArticleResource.delete({ id: 500 });
|
|
308
|
+
expect(res).toEqual({ id: 500 });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('should spread `url` member', () => {
|
|
312
|
+
const entity = UrlArticle.fromJS({ url: 'five' });
|
|
313
|
+
const spread = { ...entity };
|
|
314
|
+
expect(spread.url).toBe('five');
|
|
315
|
+
expect(Object.hasOwn(entity, 'url')).toBeTruthy();
|
|
316
|
+
});
|
|
317
|
+
});
|