UNPKG

12.6 kBPlain TextView Raw
1import * as LinkHeader from 'http-link-header';
2import FollowablePromise from './followable-promise';
3import problemFactory from './http-error';
4import Ketting from './ketting';
5import Link from './link';
6import Representation from './representor/base';
7import { mergeHeaders } from './utils/fetch-helper';
8import { resolve } from './utils/url';
9
10/**
11 * A 'resource' represents an endpoint on the server.
12 *
13 * The endpoint has a uri, you might for example be able to GET its
14 * presentation.
15 *
16 * A resource may also have a list of links on them, pointing to other
17 * resources.
18 *
19 * @param {Client} client
20 * @param {string} uri
21 * @constructor
22 */
23export default class Resource<T = any> {
24
25 /**
26 * Reference to the main Client
27 */
28 client: Ketting;
29
30 /**
31 * The current representation, or body of the resource.
32 */
33 repr: Representation | null;
34
35 /**
36 * The uri of the resource
37 */
38 uri: string;
39
40 /**
41 * A default mimetype for the resource.
42 *
43 * This mimetype is used for PUT and POST requests by default.
44 * The mimetype is sniffed in a few different ways.
45 *
46 * If a GET request is done, and the GET request had a mimetype it will
47 * be used to set this value.
48 *
49 * It's also possible for resources to get a mimetype through a link.
50 *
51 * If the mimetype was "null" when doing the request, the chosen mimetype
52 * will come from the first item in Client.resourceTypes
53 */
54 contentType: string | null;
55
56 private inFlightRefresh: Promise<any> = null;
57
58 /**
59 * A list of rels that should be added to a Prefer-Push header.
60 */
61 private preferPushRels: Set<string>;
62
63 constructor(client: Ketting, uri: string, contentType: string = null) {
64
65 this.client = client;
66 this.uri = uri;
67 this.repr = null;
68 this.contentType = contentType;
69 this.preferPushRels = new Set();
70
71 }
72
73 /**
74 * Fetches the resource representation.
75 * Returns a promise that resolves to a parsed json object.
76 */
77 async get(): Promise<T> {
78
79 const r = await this.representation();
80 return r.body;
81
82 }
83
84 /**
85 * Updates the resource representation with a new JSON object.
86 */
87 async put(body: T): Promise<void> {
88
89 const contentType = this.contentType || this.client.contentTypes[0].mime;
90 const params = {
91 method: 'PUT',
92 body: JSON.stringify(body),
93 headers: {
94 'Content-Type': contentType,
95 'Accept' : this.contentType ? this.contentType : this.client.getAcceptHeader()
96 },
97 };
98 await this.fetchAndThrow(params);
99
100 }
101
102 /**
103 * Updates the resource representation with a new JSON object.
104 */
105 async delete(): Promise<void> {
106
107 await this.fetchAndThrow({ method: 'DELETE' });
108
109 }
110
111 /**
112 * Sends a POST request to the resource.
113 *
114 * This function assumes that POST is used to create new resources, and
115 * that the response will be a 201 Created along with a Location header that
116 * identifies the new resource location.
117 *
118 * This function returns a Promise that resolves into the newly created
119 * Resource.
120 *
121 * If no Location header was given, it will resolve still, but with an empty
122 * value.
123 */
124 async post(body: object): Promise<Resource|null> {
125
126 const contentType = this.contentType || this.client.contentTypes[0].mime;
127 const response = await this.fetchAndThrow(
128 {
129 method: 'POST',
130 body: JSON.stringify(body),
131 headers: {
132 'Content-Type': contentType,
133 }
134 }
135 );
136
137 if (response.headers.has('location')) {
138 return this.go(<string> response.headers.get('location'));
139 }
140 return null;
141
142 }
143
144 /**
145 * Sends a PATCH request to the resource.
146 *
147 * This function defaults to a application/json content-type header.
148 */
149 async patch(body: object): Promise<void> {
150
151 await this.fetchAndThrow(
152 {
153 method: 'PATCH',
154 body: JSON.stringify(body),
155 headers: {
156 'Content-Type' : 'application/json'
157 }
158 }
159 );
160
161 }
162
163 /**
164 * Refreshes the representation for this resource.
165 *
166 * This function will return the a parsed JSON object, like the get
167 * function does.
168 *
169 * @return {object}
170 */
171 async refresh(): Promise<T> {
172
173 let response: Response;
174 let body: string;
175
176 // If 2 systems request a 'refresh' at the exact same time, this mechanism
177 // will coalesc them into one.
178 if (!this.inFlightRefresh) {
179
180 const headers: { [name: string]: string } = {
181 Accept: this.contentType ? this.contentType : this.client.getAcceptHeader()
182 };
183
184 if (this.preferPushRels.size > 0) {
185 headers['Prefer-Push'] = Array.from(this.preferPushRels).join(' ');
186 headers.Prefer = 'transclude="' + Array.from(this.preferPushRels).join(';') + '"';
187 }
188
189 this.inFlightRefresh = this.fetchAndThrow({
190 method: 'GET' ,
191 headers
192 }).then( result1 => {
193 response = result1;
194 return response.text();
195 })
196 .then( result2 => {
197 body = result2;
198 return [response, body];
199 });
200
201 try {
202 await this.inFlightRefresh;
203 } finally {
204 this.inFlightRefresh = null;
205 }
206
207 } else {
208 // Something else asked for refresh, so we piggypack on it.
209 [response, body] = await this.inFlightRefresh;
210
211 }
212
213 const contentType = response.headers.get('Content-Type');
214 if (!contentType) {
215 throw new Error('Server did not respond with a Content-Type header');
216 }
217 this.repr = new (this.client.getRepresentor(contentType))(
218 this.uri,
219 contentType,
220 body
221 );
222
223 if (!this.contentType) {
224 this.contentType = contentType;
225 }
226
227 // Extracting HTTP Link header.
228 const httpLinkHeader = response.headers.get('Link');
229 if (httpLinkHeader) {
230
231 for (const httpLink of LinkHeader.parse(httpLinkHeader).refs) {
232 // Looping through individual links
233 for (const rel of httpLink.rel.split(' ')) {
234 // Looping through space separated rel values.
235 this.repr.links.push(
236 new Link({
237 rel: rel,
238 context: this.uri,
239 href: httpLink.uri
240 })
241 );
242 }
243 }
244
245 }
246
247 // Parsing and storing embedded uris
248 for (const uri of Object.keys(this.repr.embedded)) {
249 const subResource = this.go(uri);
250 subResource.repr = new (this.client.getRepresentor(contentType))(
251 uri,
252 contentType,
253 this.repr.embedded[uri]
254 );
255 }
256
257 return this.repr.body;
258
259 }
260
261 /**
262 * Returns the links for this resource, as a promise.
263 *
264 * The rel argument is optional. If it's given, we will only return links
265 * from that relationship type.
266 */
267 async links(rel?: string): Promise<Link[]> {
268
269 const r = await this.representation();
270
271 // After we got a representation, it no longer makes sense to remember
272 // the rels we want to add to Prefer-Push.
273 this.preferPushRels = new Set();
274
275 if (!rel) { return r.links; }
276
277 return r.links.filter( item => item.rel === rel );
278
279 }
280
281 /**
282 * Follows a relationship, based on its reltype. For example, this might be
283 * 'alternate', 'item', 'edit' or a custom url-based one.
284 *
285 * This function can also follow templated uris. You can specify uri
286 * variables in the optional variables argument.
287 */
288 follow(rel: string, variables?: object): FollowablePromise {
289
290 this.preferPushRels.add(rel);
291
292 return new FollowablePromise(async (res: any, rej: any) => {
293
294 try {
295 const links = await this.links(rel);
296
297 let href;
298 if (links.length === 0) {
299 throw new Error('Relation with type ' + rel + ' not found on resource ' + this.uri);
300 }
301 if (links[0].templated && variables) {
302 href = links[0].expand(variables);
303 } else {
304 href = links[0].resolve();
305 }
306
307 const resource = this.go(href);
308 if (links[0].type) {
309 resource.contentType = links[0].type;
310 }
311
312 res(resource);
313
314 } catch (reason) {
315 rej(reason);
316 }
317
318 });
319
320 }
321
322 /**
323 * Follows a relationship based on its reltype. This function returns a
324 * Promise that resolves to an array of Resource objects.
325 *
326 * If no resources were found, the array will be empty.
327 */
328 async followAll(rel: string): Promise<Resource[]> {
329
330 this.preferPushRels.add(rel);
331 const links = await this.links(rel);
332
333 return links.map((link: Link) => {
334 const resource = this.go(link.resolve());
335 if (link.type) {
336 resource.contentType = link.type;
337 }
338 return resource;
339 });
340
341 }
342
343 /**
344 * Resolves a new resource based on a relative uri.
345 *
346 * Use this function to manually get a Resource object via a uri. The uri
347 * will be resolved based on the uri of the current resource.
348 *
349 * This function doesn't do any HTTP requests.
350 */
351 go(uri: string): Resource {
352
353 uri = resolve(this.uri, uri);
354 return this.client.go(uri);
355
356 }
357
358 /**
359 * Returns the representation for the object.
360 * If it wasn't fetched yet, this function does the fetch as well.
361 *
362 * Usually you will want to use the `get()` method instead, unless you need
363 * the full object.
364 */
365 async representation(): Promise<Representation> {
366
367 if (!this.repr) {
368 await this.refresh();
369 }
370
371 return <Representation> this.repr;
372
373 }
374
375 clearCache(): void {
376
377 this.repr = null;
378
379 }
380
381 /**
382 * Does an arbitrary HTTP request on the resource using the Fetch API.
383 *
384 * The method signature is the same as the MDN fetch object. However, it's
385 * possible in this case to not specify a URI or specify a relative URI.
386 *
387 * When doing the actual request, any relative uri will be resolved to the
388 * uri of the current resource.
389 *
390 * @see https://developer.mozilla.org/en-US/docs/Web/API/Request/Request
391 */
392 fetch(input: Request|string|RequestInit, init?: RequestInit): Promise<Response> {
393
394 let uri = null;
395 let newInit: RequestInit = {};
396
397 if (input === undefined) {
398 // Nothing was provided, we're operating on the resource uri.
399 uri = this.uri;
400 } else if (typeof input === 'string') {
401 // If it's a string, it might be relative uri so we're resolving it
402 // first.
403 uri = resolve(this.uri, input);
404
405 } else if (input instanceof Request) {
406 // We were passed a request object. We need to extract all its
407 // information into the init object.
408 uri = resolve(this.uri, (<Request> input).url);
409
410 newInit.method = input.method;
411 newInit.headers = new Headers(input.headers);
412 // @ts-ignore: Possibly an error due to https://github.com/Microsoft/TypeScript/issues/15199
413 newInit.body = input.body;
414 newInit.mode = input.mode;
415 newInit.credentials = input.credentials;
416 newInit.cache = input.cache;
417 newInit.redirect = input.redirect;
418 newInit.referrer = input.referrer;
419 newInit.integrity = input.integrity;
420
421 } else if (input instanceof Object) {
422 // if it was a regular 'object', but not a Request, we're assuming the
423 // method was called with the init object as it's first parameter. This
424 // is not allowed in the default Fetch API, but we do allow it because
425 // in the resource, specifying the uri is optional.
426 uri = this.uri;
427 newInit = <RequestInit> input;
428 } else {
429 throw new TypeError('When specified, input must be a string, Request object or a key-value object');
430 }
431
432 // if the 'init' argument is specified, we're using it to override things
433 // in newInit.
434 if (init) {
435
436 for (const key of Object.keys(init)) {
437 if (key === 'headers') {
438 // special case.
439 continue;
440 }
441 (<any> newInit)[key] = (<any> init)[key];
442 }
443 newInit.headers = mergeHeaders([
444 newInit.headers,
445 init.headers
446 ]);
447 }
448
449 // @ts-ignore cross-fetch definitions are broken. See https://github.com/lquixada/cross-fetch/pull/19
450 const request = new Request(uri, newInit);
451
452 return this.client.fetch(request);
453
454 }
455
456 /**
457 * Does a HTTP request and throws an exception if the server emitted
458 * a HTTP error.
459 *
460 * @see https://developer.mozilla.org/en-US/docs/Web/API/Request/Request
461 */
462 async fetchAndThrow(input: Request|string|RequestInit, init?: RequestInit): Promise<Response> {
463
464 const response = await this.fetch(input, init);
465
466 if (response.ok) {
467 return response;
468 } else {
469 throw await problemFactory(response);
470 }
471
472 }
473
474}