1 | import * as LinkHeader from 'http-link-header';
|
2 | import FollowablePromise from './followable-promise';
|
3 | import problemFactory from './http-error';
|
4 | import Ketting from './ketting';
|
5 | import Link from './link';
|
6 | import Representation from './representor/base';
|
7 | import { mergeHeaders } from './utils/fetch-helper';
|
8 | import { resolve } from './utils/url';
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 | export default class Resource<T = any> {
|
24 |
|
25 | |
26 |
|
27 |
|
28 | client: Ketting;
|
29 |
|
30 | |
31 |
|
32 |
|
33 | repr: Representation | null;
|
34 |
|
35 | |
36 |
|
37 |
|
38 | uri: string;
|
39 |
|
40 | |
41 |
|
42 |
|
43 |
|
44 |
|
45 |
|
46 |
|
47 |
|
48 |
|
49 |
|
50 |
|
51 |
|
52 |
|
53 |
|
54 | contentType: string | null;
|
55 |
|
56 | private inFlightRefresh: Promise<any> = null;
|
57 |
|
58 | |
59 |
|
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 |
|
75 |
|
76 |
|
77 | async get(): Promise<T> {
|
78 |
|
79 | const r = await this.representation();
|
80 | return r.body;
|
81 |
|
82 | }
|
83 |
|
84 | |
85 |
|
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 |
|
104 |
|
105 | async delete(): Promise<void> {
|
106 |
|
107 | await this.fetchAndThrow({ method: 'DELETE' });
|
108 |
|
109 | }
|
110 |
|
111 | |
112 |
|
113 |
|
114 |
|
115 |
|
116 |
|
117 |
|
118 |
|
119 |
|
120 |
|
121 |
|
122 |
|
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 |
|
146 |
|
147 |
|
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 |
|
165 |
|
166 |
|
167 |
|
168 |
|
169 |
|
170 |
|
171 | async refresh(): Promise<T> {
|
172 |
|
173 | let response: Response;
|
174 | let body: string;
|
175 |
|
176 |
|
177 |
|
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 |
|
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 |
|
228 | const httpLinkHeader = response.headers.get('Link');
|
229 | if (httpLinkHeader) {
|
230 |
|
231 | for (const httpLink of LinkHeader.parse(httpLinkHeader).refs) {
|
232 |
|
233 | for (const rel of httpLink.rel.split(' ')) {
|
234 |
|
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 |
|
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 |
|
263 |
|
264 |
|
265 |
|
266 |
|
267 | async links(rel?: string): Promise<Link[]> {
|
268 |
|
269 | const r = await this.representation();
|
270 |
|
271 |
|
272 |
|
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 |
|
283 |
|
284 |
|
285 |
|
286 |
|
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 |
|
324 |
|
325 |
|
326 |
|
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 |
|
345 |
|
346 |
|
347 |
|
348 |
|
349 |
|
350 |
|
351 | go(uri: string): Resource {
|
352 |
|
353 | uri = resolve(this.uri, uri);
|
354 | return this.client.go(uri);
|
355 |
|
356 | }
|
357 |
|
358 | |
359 |
|
360 |
|
361 |
|
362 |
|
363 |
|
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 |
|
383 |
|
384 |
|
385 |
|
386 |
|
387 |
|
388 |
|
389 |
|
390 |
|
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 |
|
399 | uri = this.uri;
|
400 | } else if (typeof input === 'string') {
|
401 |
|
402 |
|
403 | uri = resolve(this.uri, input);
|
404 |
|
405 | } else if (input instanceof Request) {
|
406 |
|
407 |
|
408 | uri = resolve(this.uri, (<Request> input).url);
|
409 |
|
410 | newInit.method = input.method;
|
411 | newInit.headers = new Headers(input.headers);
|
412 |
|
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 |
|
423 |
|
424 |
|
425 |
|
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 |
|
433 |
|
434 | if (init) {
|
435 |
|
436 | for (const key of Object.keys(init)) {
|
437 | if (key === 'headers') {
|
438 |
|
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 |
|
450 | const request = new Request(uri, newInit);
|
451 |
|
452 | return this.client.fetch(request);
|
453 |
|
454 | }
|
455 |
|
456 | |
457 |
|
458 |
|
459 |
|
460 |
|
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 | }
|