Skip to content

Commit b395d8c

Browse files
authored
Add Resources class for batch state fetching (#555)
1 parent 561f54e commit b395d8c

6 files changed

Lines changed: 148 additions & 8 deletions

File tree

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export { default as Client, default as Ketting, default } from './client.js';
22
export { default as Resource } from './resource.js';
33

4+
export { Resources } from './state/resources.js';
5+
46
export { type Link, LinkNotFound, Links, type LinkVariables } from './link.js';
57

68
export { resolve } from './util/uri.js';

src/state/base-state.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { State, HeadState } from './interface.js';
2+
import { Resources } from './resources.js';
23
import { Links, LinkVariables, LinkNotFound } from '../link.js';
34
import Client from '../client.js';
45
import { Action, ActionNotFound, ActionInfo, SimpleAction } from '../action.js';
@@ -99,19 +100,16 @@ export class BaseHeadState implements HeadState {
99100
*
100101
* If no resources were found, the array will be empty.
101102
*/
102-
followAll<TFollowedResource = any>(rel: string): Resource<TFollowedResource>[] {
103-
104-
return this.links.getMany(rel).map( link => {
105-
103+
followAll<TFollowedResource = any>(rel: string): Resources<TFollowedResource> {
104+
const resources = this.links.getMany(rel).map( link => {
106105
if (link.hints?.status === 'deprecated') {
107106
/* eslint-disable-next-line no-console */
108107
console.warn(`[ketting] The ${link.rel} link on ${this.uri} is marked deprecated.`, link);
109108
}
110109
const href = resolve(link);
111-
return this.client.go(href);
112-
110+
return this.client.go<TFollowedResource>(href);
113111
});
114-
112+
return new Resources(resources);
115113
}
116114

117115

src/state/interface.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Links, LinkVariables } from '../link.js';
33
import Client from '../client.js';
44
import { Resource } from '../resource.js';
55
import { StateSerializedBody } from '#state-serialized-body';
6+
import { Resources } from './resources.js';
67

78
export type State<T = any> = {
89

@@ -48,7 +49,7 @@ export type State<T = any> = {
4849
*
4950
* If no resources were found, the array will be empty.
5051
*/
51-
followAll<TFollowedResource = any>(rel: string): Resource<TFollowedResource>[];
52+
followAll<TFollowedResource = any>(rel: string): Resources<TFollowedResource>;
5253

5354
/**
5455
* Return an action by name.

src/state/resources.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { State } from './interface.js';
2+
import { Resource } from '../resource.js';
3+
import { GetRequestOptions } from '../types.js';
4+
5+
export class Resources<T = any> extends Array<Resource<T>> {
6+
7+
static get [Symbol.species]() { return Array; }
8+
9+
constructor(resources: Resource<T>[]) {
10+
super();
11+
this.push(...resources);
12+
}
13+
14+
public async get(getOptions?: GetRequestOptions): Promise<State<T>[]> {
15+
return Promise.all(this.map(resource => resource.get(getOptions)));
16+
}
17+
}

test/integration/follow-hal.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,20 @@ describe('Following a link', async () => {
9696

9797
});
9898

99+
it('should resolve states when .get() is called on the State#followAll() result', async ({testApplicationUris}) => {
100+
101+
const serverUri = testApplicationUris.createTenantUri();
102+
const client = new Client(serverUri + '/hal1.json');
103+
104+
const collectionState = await client.follow('collection').get();
105+
106+
const items = await collectionState.followAll('item').get();
107+
expect(items).to.have.length(2);
108+
expect(isState(items[0])).to.eq(true);
109+
expect(isState(items[1])).to.eq(true);
110+
111+
});
112+
99113
it('should remember the type="" property for later usage', async ({testApplicationUris}) => {
100114

101115
const serverUri = testApplicationUris.createTenantUri();

test/unit/state/resources.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, it, expect } from '#ketting-test';
2+
3+
import { Resource, Links } from 'ketting';
4+
import { Resources } from '#ketting-dist/state/resources.js';
5+
6+
describe('Resources', () => {
7+
8+
it('should behave like an Array', () => {
9+
10+
const fakeResources = [getFakeResource('/a'), getFakeResource('/b')];
11+
const resources = new Resources(fakeResources);
12+
13+
expect(resources).to.be.an.instanceof(Array);
14+
const uris = resources.map(r => r.uri);
15+
expect(uris).to.eql(['/a', '/b']);
16+
17+
resources.push(getFakeResource('/c'));
18+
19+
expect(resources[2].uri).to.eql('/c');
20+
21+
});
22+
23+
it('should resolve all resources to their state when get() is called', async () => {
24+
25+
const fakeResources = [getFakeResource('/a'), getFakeResource('/b')];
26+
const resources = new Resources(fakeResources);
27+
28+
const states = await resources.get();
29+
expect(states).to.have.length(2);
30+
expect(states[0].uri).to.eql('/a');
31+
expect(states[1].uri).to.eql('/b');
32+
33+
});
34+
35+
it('should return an empty array from get() when there are no resources', async () => {
36+
37+
const resources = new Resources([]);
38+
const states = await resources.get();
39+
expect(states).to.eql([]);
40+
41+
});
42+
43+
});
44+
45+
function getFakeResource(uri: string = 'https://example.org/') {
46+
47+
const fakeFetch = (input:any) => {
48+
let url;
49+
if (input.url) {
50+
url = input.url;
51+
} else {
52+
url = input;
53+
}
54+
switch(url) {
55+
case 'https://example.org/return-request':
56+
return input;
57+
case 'https://example.org/200':
58+
return new Response('', {status: 200});
59+
case 'https://example.org/201':
60+
return new Response('', {status: 201});
61+
case 'https://example.org/201-loc':
62+
return new Response('', {status: 201, headers: { 'Location': 'https://evertpot.com/'}});
63+
case 'https://example.org/205':
64+
return new Response(null, {status: 205});
65+
}
66+
};
67+
68+
const fakeClient:any = {
69+
70+
fetcher: {
71+
72+
fetch: fakeFetch,
73+
fetchOrThrow: fakeFetch,
74+
},
75+
76+
go: (uri:string) => {
77+
78+
return getFakeResource(uri);
79+
80+
},
81+
82+
cache: {
83+
84+
get: ():null => { return null; },
85+
store: ():void => { /* Intentionally Empty */ }
86+
87+
},
88+
89+
cacheState: (state: any) => {
90+
91+
return;
92+
93+
},
94+
95+
getStateForResponse: () => {
96+
97+
return {
98+
uri,
99+
links: new Links('/', [ { href: '/', rel: 'yes', context: '/' }])
100+
};
101+
102+
}
103+
104+
};
105+
const resource = new Resource(fakeClient, uri);
106+
return resource;
107+
108+
}

0 commit comments

Comments
 (0)