From 612b0ee89ac1044b07ea8b9a127a67161187312c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Huard?= <5523410+J3m5@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:31:03 +0200 Subject: [PATCH] fix(hydra): cache resource parameters --- README.md | 5 + src/hydra/fetchResource.ts | 6 +- src/hydra/getParameters.test.ts | 132 ++++++++++++++++++++++ src/hydra/getParameters.ts | 55 ++++++--- src/hydra/parseHydraDocumentation.test.ts | 5 + src/hydra/parseHydraDocumentation.ts | 3 +- 6 files changed, 187 insertions(+), 19 deletions(-) create mode 100644 src/hydra/getParameters.test.ts diff --git a/README.md b/README.md index f8150da..70e7474 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,11 @@ interface Resource { } ``` +For Hydra resources, `getParameters()` loads parameters lazily and caches the +result for the lifetime of the `Resource` instance. Run the documentation +parser again to refresh parameters after the API schema or authorization +context changes. + ### Field Represents a property of a resource, including its type, constraints, and metadata. diff --git a/src/hydra/fetchResource.ts b/src/hydra/fetchResource.ts index 2c19927..57a741c 100644 --- a/src/hydra/fetchResource.ts +++ b/src/hydra/fetchResource.ts @@ -6,11 +6,7 @@ export default async function fetchResource( resourceUrl: string, options: RequestInitExtended = {}, ): Promise<{ parameters: IriTemplateMapping[] }> { - const response = await fetchJsonLd( - resourceUrl, - // oxlint-disable-next-line prefer-object-spread - Object.assign({ itemsPerPage: 0 }, options), - ); + const response = await fetchJsonLd(resourceUrl, options); let hasPrefix = true; if ("body" in response) { diff --git a/src/hydra/getParameters.test.ts b/src/hydra/getParameters.test.ts new file mode 100644 index 0000000..d30ce20 --- /dev/null +++ b/src/hydra/getParameters.test.ts @@ -0,0 +1,132 @@ +import { http } from "msw/core/http"; +import { expect, test } from "vitest"; +import { server } from "../../vitest.setup.js"; +import { Field, Resource } from "../core/index.js"; +import getParameters from "./getParameters.js"; + +const init = { + headers: { "Content-Type": "application/ld+json" }, + status: 200, + statusText: "OK", +}; + +const resourceCollectionWithParameters = { + "hydra:search": { + "hydra:mapping": [ + { + property: "isbn", + variable: "isbn", + required: false, + }, + ], + }, +}; + +function createResource(): Resource { + return new Resource("books", "http://localhost/books", { + fields: [ + new Field("isbn", { + range: "http://www.w3.org/2001/XMLSchema#string", + }), + ], + }); +} + +test("Resource parameters are cached and concurrent requests are deduplicated", async () => { + let discoveryRequests = 0; + server.use( + http.get("http://localhost/books", () => { + discoveryRequests += 1; + return Response.json(resourceCollectionWithParameters, init); + }), + ); + const resource = createResource(); + + const firstRequest = getParameters(resource); + const concurrentRequest = getParameters(resource); + + expect(concurrentRequest).toBe(firstRequest); + + const parameters = await firstRequest; + const cachedParameters = await getParameters(resource); + + expect(cachedParameters).toBe(parameters); + expect(resource.parameters).toBe(parameters); + expect(discoveryRequests).toBe(1); + expect(parameters).toEqual([ + { + description: "", + range: "http://www.w3.org/2001/XMLSchema#string", + required: false, + variable: "isbn", + }, + ]); +}); + +test("Empty resource parameters are cached", async () => { + let discoveryRequests = 0; + server.use( + http.get("http://localhost/books", () => { + discoveryRequests += 1; + return Response.json({}, init); + }), + ); + const resource = createResource(); + + const parameters = await getParameters(resource); + const cachedParameters = await getParameters(resource); + + expect(parameters).toEqual([]); + expect(cachedParameters).toBe(parameters); + expect(discoveryRequests).toBe(1); +}); + +test("Resource parameters can be retried after a failed request", async () => { + let attempts = 0; + server.use( + http.get("http://localhost/books", () => { + attempts += 1; + return new Response(null, { status: 500 }); + }), + ); + const resource = createResource(); + + await expect(getParameters(resource)).rejects.toBeDefined(); + + server.use( + http.get("http://localhost/books", () => { + attempts += 1; + return Response.json(resourceCollectionWithParameters, init); + }), + ); + + await expect(getParameters(resource)).resolves.toEqual([ + { + description: "", + range: "http://www.w3.org/2001/XMLSchema#string", + required: false, + variable: "isbn", + }, + ]); + expect(attempts).toBe(2); +}); + +test("Parameter caches are isolated between Resource instances", async () => { + let discoveryRequests = 0; + server.use( + http.get("http://localhost/books", () => { + discoveryRequests += 1; + return Response.json(resourceCollectionWithParameters, init); + }), + ); + const firstResource = createResource(); + const secondResource = createResource(); + + expect(firstResource.url).toBe(secondResource.url); + + await getParameters(firstResource); + await getParameters(firstResource); + await getParameters(secondResource); + + expect(discoveryRequests).toBe(2); +}); diff --git a/src/hydra/getParameters.ts b/src/hydra/getParameters.ts index 54b8818..f7b51c7 100644 --- a/src/hydra/getParameters.ts +++ b/src/hydra/getParameters.ts @@ -3,23 +3,52 @@ import { Parameter } from "../core/index.js"; import type { RequestInitExtended } from "../core/types.js"; import fetchResource from "./fetchResource.js"; -export default async function getParameters( +const parametersPromises = new WeakMap>(); + +/** + * Gets and caches parameters for the lifetime of a Resource instance. + * Rejected requests are not cached and can be retried. + * @param {Resource} resource The resource whose parameters should be loaded. + * @param {RequestInitExtended} [options] Optional fetch options. + * @returns {Promise} The cached or newly loaded parameters. + */ +export default function getParameters( resource: Resource, options: RequestInitExtended = {}, ): Promise { - const { parameters = [] } = await fetchResource(resource.url, options); - const resourceParameters: Parameter[] = []; - for (const { property = null, required, variable } of parameters) { - if (property === null) { - continue; - } + const cachedPromise = parametersPromises.get(resource); + if (cachedPromise !== undefined) { + return cachedPromise; + } - const { range = null } = - resource.fields?.find(({ name }) => property === name) || {}; + const parametersPromise = loadParameters(resource, options); + parametersPromises.set(resource, parametersPromise); - resourceParameters.push(new Parameter(variable, range, required, "")); - } - resource.parameters = resourceParameters; + return parametersPromise; +} + +async function loadParameters( + resource: Resource, + options: RequestInitExtended, +): Promise { + try { + const { parameters = [] } = await fetchResource(resource.url, options); + const resourceParameters: Parameter[] = []; + for (const { property = null, required, variable } of parameters) { + if (property === null) { + continue; + } + + const { range = null } = + resource.fields?.find(({ name }) => property === name) || {}; - return resourceParameters; + resourceParameters.push(new Parameter(variable, range, required, "")); + } + resource.parameters = resourceParameters; + + return resourceParameters; + } catch (error: unknown) { + parametersPromises.delete(resource); + throw error; + } } diff --git a/src/hydra/parseHydraDocumentation.test.ts b/src/hydra/parseHydraDocumentation.test.ts index 1bc6ebd..9245c9b 100644 --- a/src/hydra/parseHydraDocumentation.test.ts +++ b/src/hydra/parseHydraDocumentation.test.ts @@ -1589,7 +1589,11 @@ test("Resource parameters can be retrieved", async () => { assert(!!resource.getParameters); const parameters = await resource.getParameters(); + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(fetchSpy).toHaveBeenLastCalledWith("http://localhost/books", { + headers: {}, + }); expect(parameters).toEqual([ { description: "", @@ -1598,6 +1602,7 @@ test("Resource parameters can be retrieved", async () => { variable: "isbn", }, ]); + fetchSpy.mockRestore(); }); test("parse a Hydra documentation with enum/read-only resources (rdfs:range direct @id)", async () => { diff --git a/src/hydra/parseHydraDocumentation.ts b/src/hydra/parseHydraDocumentation.ts index d2fda0a..c65003e 100644 --- a/src/hydra/parseHydraDocumentation.ts +++ b/src/hydra/parseHydraDocumentation.ts @@ -549,7 +549,8 @@ export default async function parseHydraDocumentation( resource.parameters = []; resource.getParameters = /** - * Gets the parameters for the resource. + * Gets the parameters for the resource. The result is cached for the + * lifetime of this Resource instance. * @returns {Promise} The parameters for the resource. */ (): Promise => getParameters(resource, options);