1 | import Client from './client';
|
2 | import { State, headStateFactory, HeadState, isState } from './state';
|
3 | import { resolve } from './util/uri';
|
4 | import { FollowPromiseOne, FollowPromiseMany } from './follow-promise';
|
5 | import { Link, LinkNotFound, LinkVariables } from './link';
|
6 | import { EventEmitter } from 'events';
|
7 | import { GetRequestOptions, PostRequestOptions, PatchRequestOptions, PutRequestOptions, HeadRequestOptions } from './types';
|
8 | import { needsJsonStringify } from './util/fetch-body-helper';
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 | export class Resource<T = any> extends EventEmitter {
|
17 |
|
18 | |
19 |
|
20 |
|
21 | uri: string;
|
22 |
|
23 | |
24 |
|
25 |
|
26 | client: Client;
|
27 |
|
28 | private activeRefresh: Promise<State<T>> | null;
|
29 |
|
30 | |
31 |
|
32 |
|
33 |
|
34 |
|
35 | constructor(client: Client, uri: string) {
|
36 | super();
|
37 | this.client = client;
|
38 | this.uri = uri;
|
39 | this.activeRefresh = null;
|
40 |
|
41 | }
|
42 |
|
43 | |
44 |
|
45 |
|
46 |
|
47 |
|
48 | get(getOptions?: GetRequestOptions): Promise<State<T>> {
|
49 |
|
50 | const state = this.client.cache.get(this.uri);
|
51 | if (!state) {
|
52 | return this.refresh(getOptions);
|
53 | }
|
54 | return Promise.resolve(state);
|
55 |
|
56 | }
|
57 |
|
58 | |
59 |
|
60 |
|
61 |
|
62 |
|
63 |
|
64 | async head(headOptions?: HeadRequestOptions): Promise<HeadState> {
|
65 |
|
66 | let state: State|HeadState|null = this.client.cache.get(this.uri);
|
67 | if (state) {
|
68 | return state;
|
69 | }
|
70 |
|
71 | const response = await this.fetchOrThrow(
|
72 | optionsToRequestInit('HEAD', headOptions)
|
73 | );
|
74 |
|
75 | state = await headStateFactory(this.uri, response);
|
76 | return state;
|
77 |
|
78 | }
|
79 |
|
80 |
|
81 | |
82 |
|
83 |
|
84 |
|
85 |
|
86 |
|
87 | refresh(getOptions?: GetRequestOptions): Promise<State<T>> {
|
88 |
|
89 | const params: RequestInit = {
|
90 | cache: 'reload',
|
91 | };
|
92 | if (getOptions?.getContentHeaders && !getOptions?.headers) {
|
93 | params.headers = getOptions.getContentHeaders();
|
94 | } else if (!getOptions?.getContentHeaders && getOptions?.headers) {
|
95 | params.headers = getOptions.headers;
|
96 | } else if (getOptions?.getContentHeaders && getOptions?.headers) {
|
97 | params.headers = getOptions.getContentHeaders();
|
98 | params.headers = { ...getOptions.headers, ...params.headers};
|
99 | }
|
100 |
|
101 | if (!this.activeRefresh) {
|
102 | this.activeRefresh = (async() : Promise<State<T>> => {
|
103 | try {
|
104 | const response = await this.fetchOrThrow(params);
|
105 | const state = await this.client.getStateForResponse(this.uri, response);
|
106 | this.updateCache(state);
|
107 | return state;
|
108 | } finally {
|
109 | this.activeRefresh = null;
|
110 | }
|
111 | })();
|
112 | }
|
113 |
|
114 | return this.activeRefresh;
|
115 |
|
116 | }
|
117 |
|
118 | |
119 |
|
120 |
|
121 | async put(options: PutRequestOptions<T> | State): Promise<void> {
|
122 |
|
123 | const requestInit = optionsToRequestInit('PUT', options);
|
124 |
|
125 | |
126 |
|
127 |
|
128 |
|
129 |
|
130 |
|
131 |
|
132 |
|
133 | if (isState(options)) {
|
134 | requestInit.headers.set('X-KETTING-NO-STALE', '1');
|
135 | }
|
136 |
|
137 | await this.fetchOrThrow(requestInit);
|
138 |
|
139 | if (isState(options)) {
|
140 | this.updateCache(options);
|
141 |
|
142 | }
|
143 |
|
144 | }
|
145 |
|
146 | |
147 |
|
148 |
|
149 | async delete(): Promise<void> {
|
150 |
|
151 | await this.fetchOrThrow(
|
152 | { method: 'DELETE' }
|
153 | );
|
154 |
|
155 | }
|
156 |
|
157 | |
158 |
|
159 |
|
160 |
|
161 |
|
162 |
|
163 |
|
164 |
|
165 | async post(options: PostRequestOptions): Promise<State> {
|
166 |
|
167 | const response = await this.fetchOrThrow(
|
168 | optionsToRequestInit('POST', options)
|
169 | );
|
170 |
|
171 | return this.client.getStateForResponse(this.uri, response);
|
172 |
|
173 | }
|
174 |
|
175 | |
176 |
|
177 |
|
178 |
|
179 |
|
180 |
|
181 |
|
182 |
|
183 |
|
184 | async postFollow(options: PostRequestOptions): Promise<Resource> {
|
185 |
|
186 | const response = await this.fetchOrThrow(
|
187 | optionsToRequestInit('POST', options)
|
188 | );
|
189 |
|
190 | switch (response.status) {
|
191 | case 201:
|
192 | if (response.headers.has('location')) {
|
193 | return this.go(<string> response.headers.get('location'));
|
194 | }
|
195 | throw new Error('Could not follow after a 201 request, because the server did not reply with a Location header');
|
196 | case 204 :
|
197 | case 205 :
|
198 | return this;
|
199 | default:
|
200 | throw new Error('Did not receive a 201, 204 or 205 status code so we could not follow to the next resource');
|
201 | }
|
202 |
|
203 | }
|
204 |
|
205 | |
206 |
|
207 |
|
208 |
|
209 |
|
210 |
|
211 |
|
212 | async patch(options: PatchRequestOptions): Promise<void | State<T>> {
|
213 |
|
214 | const response = await this.fetchOrThrow(
|
215 | optionsToRequestInit('PATCH', options)
|
216 | );
|
217 |
|
218 | if (response.status === 200) {
|
219 | return await this.client.getStateForResponse(this.uri, response);
|
220 | }
|
221 | }
|
222 |
|
223 | |
224 |
|
225 |
|
226 |
|
227 |
|
228 |
|
229 |
|
230 | follow<TFollowedResource = any>(rel: string, variables?: LinkVariables): FollowPromiseOne<TFollowedResource> {
|
231 |
|
232 | return new FollowPromiseOne(this, rel, variables);
|
233 |
|
234 | }
|
235 |
|
236 | |
237 |
|
238 |
|
239 |
|
240 |
|
241 |
|
242 | followAll<TFollowedResource = any>(rel: string): FollowPromiseMany<TFollowedResource> {
|
243 |
|
244 | return new FollowPromiseMany(this, rel);
|
245 |
|
246 | }
|
247 |
|
248 | |
249 |
|
250 |
|
251 |
|
252 |
|
253 |
|
254 |
|
255 |
|
256 | go<TGoResource = any>(uri: string): Resource<TGoResource> {
|
257 |
|
258 | uri = resolve(this.uri, uri);
|
259 | return this.client.go(uri);
|
260 |
|
261 | }
|
262 |
|
263 | |
264 |
|
265 |
|
266 | fetch(init?: RequestInit): Promise<Response> {
|
267 |
|
268 | return this.client.fetcher.fetch(this.uri, init);
|
269 |
|
270 | }
|
271 |
|
272 | |
273 |
|
274 |
|
275 |
|
276 |
|
277 |
|
278 | fetchOrThrow(init?: RequestInit): Promise<Response> {
|
279 |
|
280 | return this.client.fetcher.fetchOrThrow(this.uri, init);
|
281 |
|
282 | }
|
283 |
|
284 | |
285 |
|
286 |
|
287 |
|
288 |
|
289 | updateCache(state: State<T>) {
|
290 |
|
291 | if (state.uri !== this.uri) {
|
292 | throw new Error('When calling updateCache on a resource, the uri of the State object must match the uri of the Resource');
|
293 | }
|
294 | this.client.cacheState(state);
|
295 |
|
296 | }
|
297 |
|
298 | |
299 |
|
300 |
|
301 | clearCache(): void {
|
302 |
|
303 | this.client.cache.delete(this.uri);
|
304 |
|
305 | }
|
306 |
|
307 | |
308 |
|
309 |
|
310 |
|
311 |
|
312 |
|
313 |
|
314 | async link(rel: string): Promise<Link> {
|
315 |
|
316 | const state = await this.get();
|
317 | const link = state.links.get(rel);
|
318 |
|
319 | if (!link) {
|
320 | throw new LinkNotFound(`Link with rel: ${rel} not found on ${this.uri}`);
|
321 | }
|
322 | return link;
|
323 |
|
324 | }
|
325 |
|
326 | |
327 |
|
328 |
|
329 |
|
330 |
|
331 | async links(rel?: string): Promise<Link[]> {
|
332 |
|
333 | const state = await this.get();
|
334 | if (!rel) {
|
335 | return state.links.getAll();
|
336 | } else {
|
337 | return state.links.getMany(rel);
|
338 | }
|
339 |
|
340 | }
|
341 |
|
342 | |
343 |
|
344 |
|
345 |
|
346 |
|
347 |
|
348 |
|
349 | async hasLink(rel: string): Promise<boolean> {
|
350 |
|
351 | const state = await this.get();
|
352 | return state.links.has(rel);
|
353 |
|
354 | }
|
355 | }
|
356 |
|
357 |
|
358 |
|
359 | export declare interface Resource<T = any> {
|
360 |
|
361 | |
362 |
|
363 |
|
364 |
|
365 |
|
366 |
|
367 |
|
368 |
|
369 |
|
370 |
|
371 | on(event: 'update', listener: (state: State) => void) : this
|
372 |
|
373 | |
374 |
|
375 |
|
376 |
|
377 |
|
378 |
|
379 |
|
380 |
|
381 | on(event: 'stale', listener: () => void) : this;
|
382 |
|
383 | |
384 |
|
385 |
|
386 |
|
387 |
|
388 | on(event: 'delete', listener: () => void) : this;
|
389 |
|
390 | |
391 |
|
392 |
|
393 |
|
394 | once(event: 'update', listener: (state: State) => void) : this
|
395 |
|
396 | |
397 |
|
398 |
|
399 |
|
400 | once(event: 'stale', listener: () => void) : this;
|
401 |
|
402 | |
403 |
|
404 |
|
405 |
|
406 | once(event: 'delete', listener: () => void) : this;
|
407 |
|
408 | |
409 |
|
410 |
|
411 | off(event: 'update', listener: (state: State) => void) : this
|
412 |
|
413 | |
414 |
|
415 |
|
416 | off(event: 'stale', listener: () => void) : this;
|
417 |
|
418 | |
419 |
|
420 |
|
421 | off(event: 'delete', listener: () => void) : this;
|
422 |
|
423 | |
424 |
|
425 |
|
426 | emit(event: 'update', state: State) : boolean
|
427 |
|
428 | |
429 |
|
430 |
|
431 | emit(event: 'stale') : boolean;
|
432 |
|
433 | |
434 |
|
435 |
|
436 | emit(event: 'delete') : boolean;
|
437 |
|
438 | }
|
439 |
|
440 | export default Resource;
|
441 |
|
442 | type StrictRequestInit = RequestInit & {
|
443 | headers: Headers,
|
444 | };
|
445 |
|
446 |
|
447 |
|
448 |
|
449 |
|
450 |
|
451 | function optionsToRequestInit(method: 'GET', options?: GetRequestOptions): StrictRequestInit;
|
452 | function optionsToRequestInit(method: 'HEAD', options?: HeadRequestOptions): StrictRequestInit;
|
453 | function optionsToRequestInit(method: 'PATCH', options?: PatchRequestOptions): StrictRequestInit;
|
454 | function optionsToRequestInit(method: 'POST', options?: PostRequestOptions): StrictRequestInit;
|
455 | function optionsToRequestInit(method: 'PUT', options?: PutRequestOptions): StrictRequestInit;
|
456 | function optionsToRequestInit(method: string, options?: GetRequestOptions | PostRequestOptions | PatchRequestOptions | PutRequestOptions): StrictRequestInit {
|
457 |
|
458 | if (!options) {
|
459 | return {
|
460 | method,
|
461 | headers: new Headers(),
|
462 | };
|
463 | }
|
464 | let headers;
|
465 | if (options.getContentHeaders) {
|
466 | headers = new Headers(options.getContentHeaders());
|
467 | } else if (options.headers) {
|
468 | headers = new Headers(options.headers);
|
469 | } else {
|
470 | headers = new Headers();
|
471 | }
|
472 | if (!headers.has('Content-Type')) {
|
473 | headers.set('Content-Type', 'application/json');
|
474 | }
|
475 | let body;
|
476 | if ((options as any).serializeBody !== undefined) {
|
477 | body = (options as any).serializeBody();
|
478 | } else if ((options as any).data) {
|
479 | body = (options as any).data;
|
480 | if (needsJsonStringify(body)) {
|
481 | body = JSON.stringify(body);
|
482 | }
|
483 | } else {
|
484 | body = null;
|
485 | }
|
486 | return {
|
487 | method,
|
488 | body,
|
489 | headers,
|
490 | };
|
491 |
|
492 | }
|
493 |
|