Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 1 addition & 5 deletions src/hydra/fetchResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
132 changes: 132 additions & 0 deletions src/hydra/getParameters.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
55 changes: 42 additions & 13 deletions src/hydra/getParameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Resource, Promise<Parameter[]>>();

/**
* 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<Parameter[]>} The cached or newly loaded parameters.
*/
export default function getParameters(
resource: Resource,
options: RequestInitExtended = {},
): Promise<Parameter[]> {
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<Parameter[]> {
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;
}
}
5 changes: 5 additions & 0 deletions src/hydra/parseHydraDocumentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand All @@ -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 () => {
Expand Down
3 changes: 2 additions & 1 deletion src/hydra/parseHydraDocumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameter[]>} The parameters for the resource.
*/
(): Promise<Parameter[]> => getParameters(resource, options);
Expand Down
Loading