Skip to content

Commit 57499c5

Browse files
authored
fix(hydra): deduplicate resource parameter discovery (#179)
1 parent 5b1fafa commit 57499c5

6 files changed

Lines changed: 187 additions & 19 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ interface Resource {
175175
}
176176
```
177177

178+
For Hydra resources, `getParameters()` loads parameters lazily and caches the
179+
result for the lifetime of the `Resource` instance. Run the documentation
180+
parser again to refresh parameters after the API schema or authorization
181+
context changes.
182+
178183
### Field
179184

180185
Represents a property of a resource, including its type, constraints, and metadata.

‎src/hydra/fetchResource.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@ export default async function fetchResource(
66
resourceUrl: string,
77
options: RequestInitExtended = {},
88
): Promise<{ parameters: IriTemplateMapping[] }> {
9-
const response = await fetchJsonLd(
10-
resourceUrl,
11-
// oxlint-disable-next-line prefer-object-spread
12-
Object.assign({ itemsPerPage: 0 }, options),
13-
);
9+
const response = await fetchJsonLd(resourceUrl, options);
1410

1511
let hasPrefix = true;
1612
if ("body" in response) {

‎src/hydra/getParameters.test.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { http } from "msw/core/http";
2+
import { expect, test } from "vitest";
3+
import { server } from "../../vitest.setup.js";
4+
import { Field, Resource } from "../core/index.js";
5+
import getParameters from "./getParameters.js";
6+
7+
const init = {
8+
headers: { "Content-Type": "application/ld+json" },
9+
status: 200,
10+
statusText: "OK",
11+
};
12+
13+
const resourceCollectionWithParameters = {
14+
"hydra:search": {
15+
"hydra:mapping": [
16+
{
17+
property: "isbn",
18+
variable: "isbn",
19+
required: false,
20+
},
21+
],
22+
},
23+
};
24+
25+
function createResource(): Resource {
26+
return new Resource("books", "http://localhost/books", {
27+
fields: [
28+
new Field("isbn", {
29+
range: "http://www.w3.org/2001/XMLSchema#string",
30+
}),
31+
],
32+
});
33+
}
34+
35+
test("Resource parameters are cached and concurrent requests are deduplicated", async () => {
36+
let discoveryRequests = 0;
37+
server.use(
38+
http.get("http://localhost/books", () => {
39+
discoveryRequests += 1;
40+
return Response.json(resourceCollectionWithParameters, init);
41+
}),
42+
);
43+
const resource = createResource();
44+
45+
const firstRequest = getParameters(resource);
46+
const concurrentRequest = getParameters(resource);
47+
48+
expect(concurrentRequest).toBe(firstRequest);
49+
50+
const parameters = await firstRequest;
51+
const cachedParameters = await getParameters(resource);
52+
53+
expect(cachedParameters).toBe(parameters);
54+
expect(resource.parameters).toBe(parameters);
55+
expect(discoveryRequests).toBe(1);
56+
expect(parameters).toEqual([
57+
{
58+
description: "",
59+
range: "http://www.w3.org/2001/XMLSchema#string",
60+
required: false,
61+
variable: "isbn",
62+
},
63+
]);
64+
});
65+
66+
test("Empty resource parameters are cached", async () => {
67+
let discoveryRequests = 0;
68+
server.use(
69+
http.get("http://localhost/books", () => {
70+
discoveryRequests += 1;
71+
return Response.json({}, init);
72+
}),
73+
);
74+
const resource = createResource();
75+
76+
const parameters = await getParameters(resource);
77+
const cachedParameters = await getParameters(resource);
78+
79+
expect(parameters).toEqual([]);
80+
expect(cachedParameters).toBe(parameters);
81+
expect(discoveryRequests).toBe(1);
82+
});
83+
84+
test("Resource parameters can be retried after a failed request", async () => {
85+
let attempts = 0;
86+
server.use(
87+
http.get("http://localhost/books", () => {
88+
attempts += 1;
89+
return new Response(null, { status: 500 });
90+
}),
91+
);
92+
const resource = createResource();
93+
94+
await expect(getParameters(resource)).rejects.toBeDefined();
95+
96+
server.use(
97+
http.get("http://localhost/books", () => {
98+
attempts += 1;
99+
return Response.json(resourceCollectionWithParameters, init);
100+
}),
101+
);
102+
103+
await expect(getParameters(resource)).resolves.toEqual([
104+
{
105+
description: "",
106+
range: "http://www.w3.org/2001/XMLSchema#string",
107+
required: false,
108+
variable: "isbn",
109+
},
110+
]);
111+
expect(attempts).toBe(2);
112+
});
113+
114+
test("Parameter caches are isolated between Resource instances", async () => {
115+
let discoveryRequests = 0;
116+
server.use(
117+
http.get("http://localhost/books", () => {
118+
discoveryRequests += 1;
119+
return Response.json(resourceCollectionWithParameters, init);
120+
}),
121+
);
122+
const firstResource = createResource();
123+
const secondResource = createResource();
124+
125+
expect(firstResource.url).toBe(secondResource.url);
126+
127+
await getParameters(firstResource);
128+
await getParameters(firstResource);
129+
await getParameters(secondResource);
130+
131+
expect(discoveryRequests).toBe(2);
132+
});

‎src/hydra/getParameters.ts‎

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,52 @@ import { Parameter } from "../core/index.js";
33
import type { RequestInitExtended } from "../core/types.js";
44
import fetchResource from "./fetchResource.js";
55

6-
export default async function getParameters(
6+
const parametersPromises = new WeakMap<Resource, Promise<Parameter[]>>();
7+
8+
/**
9+
* Gets and caches parameters for the lifetime of a Resource instance.
10+
* Rejected requests are not cached and can be retried.
11+
* @param {Resource} resource The resource whose parameters should be loaded.
12+
* @param {RequestInitExtended} [options] Optional fetch options.
13+
* @returns {Promise<Parameter[]>} The cached or newly loaded parameters.
14+
*/
15+
export default function getParameters(
716
resource: Resource,
817
options: RequestInitExtended = {},
918
): Promise<Parameter[]> {
10-
const { parameters = [] } = await fetchResource(resource.url, options);
11-
const resourceParameters: Parameter[] = [];
12-
for (const { property = null, required, variable } of parameters) {
13-
if (property === null) {
14-
continue;
15-
}
19+
const cachedPromise = parametersPromises.get(resource);
20+
if (cachedPromise !== undefined) {
21+
return cachedPromise;
22+
}
1623

17-
const { range = null } =
18-
resource.fields?.find(({ name }) => property === name) || {};
24+
const parametersPromise = loadParameters(resource, options);
25+
parametersPromises.set(resource, parametersPromise);
1926

20-
resourceParameters.push(new Parameter(variable, range, required, ""));
21-
}
22-
resource.parameters = resourceParameters;
27+
return parametersPromise;
28+
}
29+
30+
async function loadParameters(
31+
resource: Resource,
32+
options: RequestInitExtended,
33+
): Promise<Parameter[]> {
34+
try {
35+
const { parameters = [] } = await fetchResource(resource.url, options);
36+
const resourceParameters: Parameter[] = [];
37+
for (const { property = null, required, variable } of parameters) {
38+
if (property === null) {
39+
continue;
40+
}
41+
42+
const { range = null } =
43+
resource.fields?.find(({ name }) => property === name) || {};
2344

24-
return resourceParameters;
45+
resourceParameters.push(new Parameter(variable, range, required, ""));
46+
}
47+
resource.parameters = resourceParameters;
48+
49+
return resourceParameters;
50+
} catch (error: unknown) {
51+
parametersPromises.delete(resource);
52+
throw error;
53+
}
2554
}

‎src/hydra/parseHydraDocumentation.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,7 +1589,11 @@ test("Resource parameters can be retrieved", async () => {
15891589
assert(!!resource.getParameters);
15901590

15911591
const parameters = await resource.getParameters();
1592+
15921593
expect(fetchSpy).toHaveBeenCalledTimes(3);
1594+
expect(fetchSpy).toHaveBeenLastCalledWith("http://localhost/books", {
1595+
headers: {},
1596+
});
15931597
expect(parameters).toEqual([
15941598
{
15951599
description: "",
@@ -1598,6 +1602,7 @@ test("Resource parameters can be retrieved", async () => {
15981602
variable: "isbn",
15991603
},
16001604
]);
1605+
fetchSpy.mockRestore();
16011606
});
16021607

16031608
test("parse a Hydra documentation with enum/read-only resources (rdfs:range direct @id)", async () => {

‎src/hydra/parseHydraDocumentation.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,8 @@ export default async function parseHydraDocumentation(
573573
resource.parameters = [];
574574
resource.getParameters =
575575
/**
576-
* Gets the parameters for the resource.
576+
* Gets the parameters for the resource. The result is cached for the
577+
* lifetime of this Resource instance.
577578
* @returns {Promise<Parameter[]>} The parameters for the resource.
578579
*/
579580
(): Promise<Parameter[]> => getParameters(resource, options);

0 commit comments

Comments
 (0)