UNPKG

12.7 kBPlain TextView Raw
1import Client from './client';
2import { State, headStateFactory, HeadState, isState } from './state';
3import { resolve } from './util/uri';
4import { FollowPromiseOne, FollowPromiseMany } from './follow-promise';
5import { Link, LinkNotFound, LinkVariables } from './link';
6import { EventEmitter } from 'events';
7import { GetRequestOptions, PostRequestOptions, PatchRequestOptions, PutRequestOptions, HeadRequestOptions } from './types';
8import { needsJsonStringify } from './util/fetch-body-helper';
9
10/**
11 * A 'resource' represents an endpoint on a server.
12 *
13 * A resource has a uri, methods that correspond to HTTP methods,
14 * and events to subscribe to state changes.
15 */
16export class Resource<T = any> extends EventEmitter {
17
18 /**
19 * URI of the current resource
20 */
21 uri: string;
22
23 /**
24 * Reference to the Client that created the resource
25 */
26 client: Client;
27
28 private activeRefresh: Promise<State<T>> | null;
29
30 /**
31 * Create the resource.
32 *
33 * This is usually done by the Client.
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 * Gets the current state of the resource.
45 *
46 * This function will return a State object.
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 * Does a HEAD request and returns a HeadState object.
60 *
61 * If there was a valid existing cache for a GET request, it will
62 * still return that.
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 * Gets the current state of the resource, skipping
83 * the cache.
84 *
85 * This function will return a State object.
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 * Updates the server state with a PUT request
120 */
121 async put(options: PutRequestOptions<T> | State): Promise<void> {
122
123 const requestInit = optionsToRequestInit('PUT', options);
124
125 /**
126 * If we got a 'State' object passed, it means we don't need to emit a
127 * stale event, as the passed object is the new
128 * state.
129 *
130 * We're gonna track that with a custom header that will be removed
131 * later in the fetch pipeline.
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 * Deletes the resource
148 */
149 async delete(): Promise<void> {
150
151 await this.fetchOrThrow(
152 { method: 'DELETE' }
153 );
154
155 }
156
157 /**
158 * Sends a POST request to the resource.
159 *
160 * See the documentation for PostRequestOptions for more details.
161 * This function is used for RPC-like endpoints and form submissions.
162 *
163 * This function will return the response as a State object.
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 * Sends a POST request, and follows to the next resource.
177 *
178 * If a server responds with a 201 Status code and a Location header,
179 * it will automatically return the newly created resource.
180 *
181 * If the server responded with a 204 or 205, this function will return
182 * `this`.
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 * Sends a PATCH request to the resource.
207 *
208 * This function defaults to a application/json content-type header.
209 *
210 * If the server responds with 200 Status code this will return a State object
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 * Follows a relationship, based on its reltype. For example, this might be
225 * 'alternate', 'item', 'edit' or a custom url-based one.
226 *
227 * This function can also follow templated uris. You can specify uri
228 * variables in the optional variables argument.
229 */
230 follow<TFollowedResource = any>(rel: string, variables?: LinkVariables): FollowPromiseOne<TFollowedResource> {
231
232 return new FollowPromiseOne(this, rel, variables);
233
234 }
235
236 /**
237 * Follows a relationship based on its reltype. This function returns a
238 * Promise that resolves to an array of Resource objects.
239 *
240 * If no resources were found, the array will be empty.
241 */
242 followAll<TFollowedResource = any>(rel: string): FollowPromiseMany<TFollowedResource> {
243
244 return new FollowPromiseMany(this, rel);
245
246 }
247
248 /**
249 * Resolves a new resource based on a relative uri.
250 *
251 * Use this function to manually get a Resource object via a uri. The uri
252 * will be resolved based on the uri of the current resource.
253 *
254 * This function doesn't do any HTTP requests.
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 * Does a HTTP request on the current resource URI
265 */
266 fetch(init?: RequestInit): Promise<Response> {
267
268 return this.client.fetcher.fetch(this.uri, init);
269
270 }
271
272 /**
273 * Does a HTTP request on the current resource URI.
274 *
275 * If the response was a 4XX or 5XX, this function will throw
276 * an exception.
277 */
278 fetchOrThrow(init?: RequestInit): Promise<Response> {
279
280 return this.client.fetcher.fetchOrThrow(this.uri, init);
281
282 }
283
284 /**
285 * Updates the state cache, and emits events.
286 *
287 * This will update the local state but *not* update the server
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 * Clears the state cache for this resource.
300 */
301 clearCache(): void {
302
303 this.client.cache.delete(this.uri);
304
305 }
306
307 /**
308 * Returns a Link object, by its REL.
309 *
310 * If the link does not exist, a LinkNotFound error will be thrown.
311 *
312 * @deprecated
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 * Returns all links defined on this object.
328 *
329 * @deprecated
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 * Returns true or false depending on if a link with the specified relation
345 * type exists.
346 *
347 * @deprecated
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// eslint doesn't like that we have a generic T but not using it.
358// eslint-disable-next-line
359export declare interface Resource<T = any> {
360
361 /**
362 * Subscribe to the 'update' event.
363 *
364 * This event will get triggered whenever a new State is received
365 * from the server, either through a GET request or if it was
366 * transcluded.
367 *
368 * It will also trigger when calling 'PUT' with a full state object,
369 * and when updateCache() was used.
370 */
371 on(event: 'update', listener: (state: State) => void) : this
372
373 /**
374 * Subscribe to the 'stale' event.
375 *
376 * This event will get triggered whenever an unsafe method was
377 * used, such as POST, PUT, PATCH, etc.
378 *
379 * When any of these methods are used, the local cache is stale.
380 */
381 on(event: 'stale', listener: () => void) : this;
382
383 /**
384 * Subscribe to the 'delete' event.
385 *
386 * This event gets triggered when the `DELETE` http method is used.
387 */
388 on(event: 'delete', listener: () => void) : this;
389
390 /**
391 * Subscribe to the 'update' event and unsubscribe after it was
392 * emitted the first time.
393 */
394 once(event: 'update', listener: (state: State) => void) : this
395
396 /**
397 * Subscribe to the 'stale' event and unsubscribe after it was
398 * emitted the first time.
399 */
400 once(event: 'stale', listener: () => void) : this;
401
402 /**
403 * Subscribe to the 'delete' event and unsubscribe after it was
404 * emitted the first time.
405 */
406 once(event: 'delete', listener: () => void) : this;
407
408 /**
409 * Unsubscribe from the 'update' event
410 */
411 off(event: 'update', listener: (state: State) => void) : this
412
413 /**
414 * Unsubscribe from the 'stale' event
415 */
416 off(event: 'stale', listener: () => void) : this;
417
418 /**
419 * Unsubscribe from the 'delete' event
420 */
421 off(event: 'delete', listener: () => void) : this;
422
423 /**
424 * Emit an 'update' event.
425 */
426 emit(event: 'update', state: State) : boolean
427
428 /**
429 * Emit a 'stale' event.
430 */
431 emit(event: 'stale') : boolean;
432
433 /**
434 * Emit a 'delete' event.
435 */
436 emit(event: 'delete') : boolean;
437
438}
439
440export default Resource;
441
442type StrictRequestInit = RequestInit & {
443 headers: Headers,
444};
445
446/**
447 * Convert request options to RequestInit
448 *
449 * RequestInit is passed to the constructor of fetch(). We have our own 'options' format
450 */
451function optionsToRequestInit(method: 'GET', options?: GetRequestOptions): StrictRequestInit;
452function optionsToRequestInit(method: 'HEAD', options?: HeadRequestOptions): StrictRequestInit;
453function optionsToRequestInit(method: 'PATCH', options?: PatchRequestOptions): StrictRequestInit;
454function optionsToRequestInit(method: 'POST', options?: PostRequestOptions): StrictRequestInit;
455function optionsToRequestInit(method: 'PUT', options?: PutRequestOptions): StrictRequestInit;
456function 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