From 738567504f479f68240b69d687296a7705506725 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Tue, 17 Dec 2024 10:31:26 +0100 Subject: [PATCH 01/23] Handle query endpoint pagination --- src/gConsent/query/query.ts | 122 ++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/gConsent/query/query.ts diff --git a/src/gConsent/query/query.ts b/src/gConsent/query/query.ts new file mode 100644 index 00000000..7e3b567c --- /dev/null +++ b/src/gConsent/query/query.ts @@ -0,0 +1,122 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import LinkHeader from "http-link-header"; + +import { handleErrorResponse } from "@inrupt/solid-client-errors"; +import { DatasetWithId } from "@inrupt/solid-client-vc"; + +export type CredentialFilter = { + type?: "SolidAccessRequest" | "SolidAccessGrant" | "SolidAccessDenial"; + status?: "Pending" | "Denied" | "Granted" | "Canceled" | "Expired" | "Active" | "Revoked"; + fromAgent?: string; + toAgent?: string; + resource?: string; + purpose?: string; + issuedWithin?: "P1D" | "P7D" | "P1M" | "P3M"; + revokedWithin?: "P1D" | "P7D" | "P1M" | "P3M"; + pageSize?: number; + page?: string; +}; + +const FILTER_ELEMENTS: (keyof CredentialFilter)[] = [ + "fromAgent","issuedWithin","page","pageSize","purpose","resource","revokedWithin","status","toAgent","type" +] as const; + +// The following ensures that, when we add a value to the FILTER_ELEMENTS array, TS complains +// if there are missing cases when handling the value. +type SupportedFilterElements = + typeof FILTER_ELEMENTS extends Array ? E : never; + +// The type assertion is okay in the context of the type guard. +const isSupportedFilterElement = (x: unknown): x is SupportedFilterElements => FILTER_ELEMENTS.includes(x as keyof CredentialFilter); + +const PAGING_RELS = [ + "first", "last", "prev", "next" +] as const; + +type SupportedPagingRels = + typeof PAGING_RELS extends Array ? E : never; + +// The type assertion is okay in the context of the type guard. +const isSupportedPagingRel = (x: unknown): x is SupportedPagingRels => PAGING_RELS.includes(x as SupportedPagingRels); + +export type CredentialResult = { + items: DatasetWithId[]; + first?: CredentialFilter; + prev?: CredentialFilter; + next?: CredentialFilter; + last?: CredentialFilter; +}; + +export async function query( + queryEndpoint: URL, + filter: CredentialFilter, + options: { + fetch?: typeof fetch; + }, +): Promise { + const queryUrl = addQueryParams(filter, queryEndpoint); + const response = await (options.fetch ?? fetch)(queryUrl); + if (!response.ok) { + throw handleErrorResponse( + response, + await response.text(), + `The query endpoint [${queryUrl}] returned an error`, + ); + } + return toCredentialResult(response); +} + +function addQueryParams(filter: CredentialFilter, url: URL): URL { + const result = new URL(url); + Object.entries(filter).forEach(([key, value]) => { + result.searchParams.append(key, value.toString()); + }) + return result; +} + +function toCredentialFilter(url: URL): CredentialFilter { + const filter: CredentialFilter = {}; + url.searchParams.forEach((value, key) => { + if (isSupportedFilterElement(key)) { + Object.assign(filter, { key: value }); + } + }); + return filter; +} + +function toCredentialResult(response: Response): CredentialResult { + const result: CredentialResult = {items: []}; + const linkHeader = response.headers.get("Link"); + if (linkHeader !== null) { + const parsedLinks = LinkHeader.parse(linkHeader); + PAGING_RELS.forEach((rel) => { + const link = parsedLinks.get("rel", rel); + if (link.length > 1) { + throw Error(`Unexpected response, found more than one ${rel} Link headers.`) + } + result[rel] = toCredentialFilter(new URL(link[0].uri)); + }); + } + // TODO parse the body to get the results + return result; +} From 51e3c79514f76dc3b60802c6645b0572546d0bf5 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Tue, 17 Dec 2024 10:47:14 +0100 Subject: [PATCH 02/23] fixup! Handle query endpoint pagination --- src/gConsent/query/query.ts | 97 ++++++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 38 deletions(-) diff --git a/src/gConsent/query/query.ts b/src/gConsent/query/query.ts index 7e3b567c..727f6703 100644 --- a/src/gConsent/query/query.ts +++ b/src/gConsent/query/query.ts @@ -22,11 +22,18 @@ import LinkHeader from "http-link-header"; import { handleErrorResponse } from "@inrupt/solid-client-errors"; -import { DatasetWithId } from "@inrupt/solid-client-vc"; +import type { DatasetWithId } from "@inrupt/solid-client-vc"; export type CredentialFilter = { type?: "SolidAccessRequest" | "SolidAccessGrant" | "SolidAccessDenial"; - status?: "Pending" | "Denied" | "Granted" | "Canceled" | "Expired" | "Active" | "Revoked"; + status?: + | "Pending" + | "Denied" + | "Granted" + | "Canceled" + | "Expired" + | "Active" + | "Revoked"; fromAgent?: string; toAgent?: string; resource?: string; @@ -38,7 +45,16 @@ export type CredentialFilter = { }; const FILTER_ELEMENTS: (keyof CredentialFilter)[] = [ - "fromAgent","issuedWithin","page","pageSize","purpose","resource","revokedWithin","status","toAgent","type" + "fromAgent", + "issuedWithin", + "page", + "pageSize", + "purpose", + "resource", + "revokedWithin", + "status", + "toAgent", + "type", ] as const; // The following ensures that, when we add a value to the FILTER_ELEMENTS array, TS complains @@ -47,17 +63,17 @@ type SupportedFilterElements = typeof FILTER_ELEMENTS extends Array ? E : never; // The type assertion is okay in the context of the type guard. -const isSupportedFilterElement = (x: unknown): x is SupportedFilterElements => FILTER_ELEMENTS.includes(x as keyof CredentialFilter); +const isSupportedFilterElement = (x: unknown): x is SupportedFilterElements => + FILTER_ELEMENTS.includes(x as keyof CredentialFilter); -const PAGING_RELS = [ - "first", "last", "prev", "next" -] as const; +const PAGING_RELS = ["first", "last", "prev", "next"] as const; type SupportedPagingRels = typeof PAGING_RELS extends Array ? E : never; // The type assertion is okay in the context of the type guard. -const isSupportedPagingRel = (x: unknown): x is SupportedPagingRels => PAGING_RELS.includes(x as SupportedPagingRels); +const isSupportedPagingRel = (x: unknown): x is SupportedPagingRels => + PAGING_RELS.includes(x as SupportedPagingRels); export type CredentialResult = { items: DatasetWithId[]; @@ -67,52 +83,30 @@ export type CredentialResult = { last?: CredentialFilter; }; -export async function query( - queryEndpoint: URL, - filter: CredentialFilter, - options: { - fetch?: typeof fetch; - }, -): Promise { - const queryUrl = addQueryParams(filter, queryEndpoint); - const response = await (options.fetch ?? fetch)(queryUrl); - if (!response.ok) { - throw handleErrorResponse( - response, - await response.text(), - `The query endpoint [${queryUrl}] returned an error`, - ); - } - return toCredentialResult(response); -} - -function addQueryParams(filter: CredentialFilter, url: URL): URL { - const result = new URL(url); - Object.entries(filter).forEach(([key, value]) => { - result.searchParams.append(key, value.toString()); - }) - return result; -} - function toCredentialFilter(url: URL): CredentialFilter { const filter: CredentialFilter = {}; url.searchParams.forEach((value, key) => { if (isSupportedFilterElement(key)) { - Object.assign(filter, { key: value }); + Object.assign(filter, { [`${key}`]: value }); } }); return filter; } function toCredentialResult(response: Response): CredentialResult { - const result: CredentialResult = {items: []}; + const result: CredentialResult = { items: [] }; const linkHeader = response.headers.get("Link"); if (linkHeader !== null) { const parsedLinks = LinkHeader.parse(linkHeader); PAGING_RELS.forEach((rel) => { const link = parsedLinks.get("rel", rel); if (link.length > 1) { - throw Error(`Unexpected response, found more than one ${rel} Link headers.`) + throw Error( + `Unexpected response, found more than one ${rel} Link headers.`, + ); + } + if (link.length === 0) { + return; } result[rel] = toCredentialFilter(new URL(link[0].uri)); }); @@ -120,3 +114,30 @@ function toCredentialResult(response: Response): CredentialResult { // TODO parse the body to get the results return result; } + +function addQueryParams(filter: CredentialFilter, url: URL): URL { + const result = new URL(url); + Object.entries(filter).forEach(([key, value]) => { + result.searchParams.append(key, value.toString()); + }); + return result; +} + +export async function query( + queryEndpoint: URL, + filter: CredentialFilter, + options: { + fetch?: typeof fetch; + }, +): Promise { + const queryUrl = addQueryParams(filter, queryEndpoint); + const response = await (options.fetch ?? fetch)(queryUrl); + if (!response.ok) { + throw handleErrorResponse( + response, + await response.text(), + `The query endpoint [${queryUrl}] returned an error`, + ); + } + return toCredentialResult(response); +} From 446944982def3eb643f4040879d803f769419967 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Tue, 17 Dec 2024 10:47:20 +0100 Subject: [PATCH 03/23] WIP: export query function for local testing --- src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/index.ts b/src/index.ts index d9e51d1b..bda9da18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,12 @@ export { REQUEST_VC_URL_PARAM_NAME, } from "./gConsent"; +export { + query, + CredentialFilter, + CredentialResult, +} from "./gConsent/query/query"; + // Add an API object to the exports to allow explicitly relying on the gConsent-based // functions even when not relying on named exports. export * as gConsent from "./gConsent"; From 6842882f5864719400f10224b1bed391aa6d7cb0 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Tue, 17 Dec 2024 11:12:25 +0100 Subject: [PATCH 04/23] Parse response credential --- src/gConsent/query/query.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/gConsent/query/query.ts b/src/gConsent/query/query.ts index 727f6703..c9241409 100644 --- a/src/gConsent/query/query.ts +++ b/src/gConsent/query/query.ts @@ -23,6 +23,7 @@ import LinkHeader from "http-link-header"; import { handleErrorResponse } from "@inrupt/solid-client-errors"; import type { DatasetWithId } from "@inrupt/solid-client-vc"; +import { verifiableCredentialToDataset } from "@inrupt/solid-client-vc"; export type CredentialFilter = { type?: "SolidAccessRequest" | "SolidAccessGrant" | "SolidAccessDenial"; @@ -93,7 +94,19 @@ function toCredentialFilter(url: URL): CredentialFilter { return filter; } -function toCredentialResult(response: Response): CredentialResult { +function hasItems(x: unknown): x is { items: unknown[] } { + const candidate = x as { items: unknown }; + return candidate.items !== undefined && Array.isArray(candidate.items); +} + +function isObjectWithId(x: unknown): x is { id: string } { + const candidate = x as { id: string }; + return typeof candidate.id === "string"; +} + +async function toCredentialResult( + response: Response, +): Promise { const result: CredentialResult = { items: [] }; const linkHeader = response.headers.get("Link"); if (linkHeader !== null) { @@ -111,7 +124,15 @@ function toCredentialResult(response: Response): CredentialResult { result[rel] = toCredentialFilter(new URL(link[0].uri)); }); } - // TODO parse the body to get the results + const body = await response.json(); + if (!hasItems(body) || !body.items.every(isObjectWithId)) { + return result; + } + result.items = await Promise.all( + body.items.map((vc) => { + return verifiableCredentialToDataset(vc); + }), + ); return result; } From 2a608d9b0d67ea20c66abeee054f2aed9a71b145 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Tue, 17 Dec 2024 11:52:04 +0100 Subject: [PATCH 05/23] Name intermediary types --- src/gConsent/index.ts | 8 ++++++ src/gConsent/query/query.ts | 53 ++++++++++++++++++++----------------- src/index.ts | 11 ++++---- 3 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/gConsent/index.ts b/src/gConsent/index.ts index fb76e150..13f92662 100644 --- a/src/gConsent/index.ts +++ b/src/gConsent/index.ts @@ -45,6 +45,14 @@ export { getAccessGrantFromRedirectUrl, } from "./request"; +export { + CredentialFilter, + CredentialResult, + CredentialStatus, + CredentialType, + query, +} from "./query/query"; + export { approveAccessRequest, denyAccessRequest, diff --git a/src/gConsent/query/query.ts b/src/gConsent/query/query.ts index c9241409..b3d53f09 100644 --- a/src/gConsent/query/query.ts +++ b/src/gConsent/query/query.ts @@ -24,28 +24,36 @@ import LinkHeader from "http-link-header"; import { handleErrorResponse } from "@inrupt/solid-client-errors"; import type { DatasetWithId } from "@inrupt/solid-client-vc"; import { verifiableCredentialToDataset } from "@inrupt/solid-client-vc"; +import type { UrlString } from "@inrupt/solid-client"; + +export type CredentialStatus = + | "Pending" + | "Denied" + | "Granted" + | "Canceled" + | "Expired" + | "Active" + | "Revoked"; + +export type CredentialType = + | "SolidAccessRequest" + | "SolidAccessGrant" + | "SolidAccessDenial"; export type CredentialFilter = { - type?: "SolidAccessRequest" | "SolidAccessGrant" | "SolidAccessDenial"; - status?: - | "Pending" - | "Denied" - | "Granted" - | "Canceled" - | "Expired" - | "Active" - | "Revoked"; - fromAgent?: string; - toAgent?: string; - resource?: string; - purpose?: string; + type?: CredentialType; + status?: CredentialStatus; + fromAgent?: UrlString; + toAgent?: UrlString; + resource?: UrlString; + purpose?: UrlString; issuedWithin?: "P1D" | "P7D" | "P1M" | "P3M"; revokedWithin?: "P1D" | "P7D" | "P1M" | "P3M"; pageSize?: number; page?: string; }; -const FILTER_ELEMENTS: (keyof CredentialFilter)[] = [ +const FILTER_ELEMENTS: Array = [ "fromAgent", "issuedWithin", "page", @@ -69,13 +77,6 @@ const isSupportedFilterElement = (x: unknown): x is SupportedFilterElements => const PAGING_RELS = ["first", "last", "prev", "next"] as const; -type SupportedPagingRels = - typeof PAGING_RELS extends Array ? E : never; - -// The type assertion is okay in the context of the type guard. -const isSupportedPagingRel = (x: unknown): x is SupportedPagingRels => - PAGING_RELS.includes(x as SupportedPagingRels); - export type CredentialResult = { items: DatasetWithId[]; first?: CredentialFilter; @@ -126,7 +127,9 @@ async function toCredentialResult( } const body = await response.json(); if (!hasItems(body) || !body.items.every(isObjectWithId)) { - return result; + throw Error( + `Unexpected response, no items found in ${JSON.stringify(body)}`, + ); } result.items = await Promise.all( body.items.map((vc) => { @@ -136,8 +139,8 @@ async function toCredentialResult( return result; } -function addQueryParams(filter: CredentialFilter, url: URL): URL { - const result = new URL(url); +function toQueryUrl(endpoint: URL, filter: CredentialFilter): URL { + const result = new URL(endpoint); Object.entries(filter).forEach(([key, value]) => { result.searchParams.append(key, value.toString()); }); @@ -151,7 +154,7 @@ export async function query( fetch?: typeof fetch; }, ): Promise { - const queryUrl = addQueryParams(filter, queryEndpoint); + const queryUrl = toQueryUrl(queryEndpoint, filter); const response = await (options.fetch ?? fetch)(queryUrl); if (!response.ok) { throw handleErrorResponse( diff --git a/src/index.ts b/src/index.ts index bda9da18..b3d25304 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,10 @@ export { CredentialIsAccessGrantAny } from "./type/AccessGrant"; export { approveAccessRequest, cancelAccessRequest, + CredentialFilter, + CredentialResult, + CredentialStatus, + CredentialType, denyAccessRequest, getAccessApiEndpoint, getAccessGrant, @@ -55,6 +59,7 @@ export { getAccessManagementUi, getAccessRequestFromRedirectUrl, issueAccessRequest, + query, redirectToAccessManagementUi, redirectToRequestor, revokeAccessGrant, @@ -63,12 +68,6 @@ export { REQUEST_VC_URL_PARAM_NAME, } from "./gConsent"; -export { - query, - CredentialFilter, - CredentialResult, -} from "./gConsent/query/query"; - // Add an API object to the exports to allow explicitly relying on the gConsent-based // functions even when not relying on named exports. export * as gConsent from "./gConsent"; From 7070de18ae814b85207872fe31205e1e92a5d8d3 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Tue, 17 Dec 2024 16:43:02 +0100 Subject: [PATCH 06/23] Make consistent with rest of API --- src/gConsent/query/query.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gConsent/query/query.ts b/src/gConsent/query/query.ts index b3d53f09..226460ad 100644 --- a/src/gConsent/query/query.ts +++ b/src/gConsent/query/query.ts @@ -148,13 +148,13 @@ function toQueryUrl(endpoint: URL, filter: CredentialFilter): URL { } export async function query( - queryEndpoint: URL, filter: CredentialFilter, options: { fetch?: typeof fetch; + queryEndpoint: URL; }, ): Promise { - const queryUrl = toQueryUrl(queryEndpoint, filter); + const queryUrl = toQueryUrl(options.queryEndpoint, filter); const response = await (options.fetch ?? fetch)(queryUrl); if (!response.ok) { throw handleErrorResponse( From ba0860ab6c5fe48bfb7a6e109d37077bb0ae966a Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Tue, 17 Dec 2024 16:43:49 +0100 Subject: [PATCH 07/23] Add e2e tests --- e2e/node/e2e.test.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/e2e/node/e2e.test.ts b/e2e/node/e2e.test.ts index e4ad1163..8dd73596 100644 --- a/e2e/node/e2e.test.ts +++ b/e2e/node/e2e.test.ts @@ -71,6 +71,7 @@ import { isValidAccessGrant, issueAccessRequest, overwriteFile, + query, revokeAccessGrant, saveFileInContainer, saveSolidDatasetAt, @@ -1712,4 +1713,51 @@ describe(`End-to-end access grant tests for environment [${environment}] `, () = }); }, ); + + describe.only("query endpoint", () => { + it("can navigate the paginated results", async () => { + const allCredentialsPageOne = await query( + { pageSize: 10 }, + { + fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), + // FIXME add query endpoint discovery check. + queryEndpoint: new URL("query", vcProvider), + }, + ); + // We should get the expected page length. + expect(allCredentialsPageOne.items).toHaveLength(10); + // The first page should not have a "prev" link. + expect(allCredentialsPageOne.prev).toBeUndefined(); + expect(allCredentialsPageOne.next).toBeDefined(); + + // Go to the next result page + const allCredentialsPageTwo = await query(allCredentialsPageOne.next!, { + fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), + // FIXME add query endpoint discovery check. + queryEndpoint: new URL("query", vcProvider), + }); + expect(allCredentialsPageTwo.items).toHaveLength(10); + }); + + it("can filter based on credential type", async () => { + const allGrantsPageOne = await query( + { type: "SolidAccessGrant" }, + { + fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), + // FIXME add query endpoint discovery check. + queryEndpoint: new URL("query", vcProvider), + }, + ); + expect(allGrantsPageOne.items).not.toHaveLength(0); + const allGrantsPageOne = await query( + { type: "SolidAccessGrant" }, + { + fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), + // FIXME add query endpoint discovery check. + queryEndpoint: new URL("query", vcProvider), + }, + ); + expect(allGrantsPageOne.items).not.toHaveLength(0); + }); + }); }); From 1b6727139f6538d40c159d9e98e1aaf92347c3b4 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Tue, 17 Dec 2024 23:10:53 +0100 Subject: [PATCH 08/23] Upgrade VC library --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9de14cb4..354eba23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@inrupt/solid-client": "^2.0.0", "@inrupt/solid-client-errors": "^0.0.2", - "@inrupt/solid-client-vc": "^1.1.2", + "@inrupt/solid-client-vc": "^1.2.0", "@types/rdfjs__dataset": "^2.0.7", "auth-header": "^1.0.0", "base64url": "^3.0.1", @@ -1322,9 +1322,9 @@ } }, "node_modules/@inrupt/solid-client-vc": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-vc/-/solid-client-vc-1.1.2.tgz", - "integrity": "sha512-+wwf16IzbiRoLYBtEcrBz9pmvdW3oWmHp5k9fRPNZZwB27N78DRSM0ietvfO9XeIzqfiWTLO4N1gm5uSgpIxOA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-vc/-/solid-client-vc-1.2.0.tgz", + "integrity": "sha512-dynCvAcbZYy+YVt17MT58Wfh6CzJTaQSDbXrNeEdWp54055tZR24CFiuQvidsoOt2M+y9rcGn9Fsf6KqXrrDYg==", "license": "MIT", "dependencies": { "@inrupt/solid-client": "^2.0.0", diff --git a/package.json b/package.json index 5616d45d..87fac953 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,7 @@ "dependencies": { "@inrupt/solid-client": "^2.0.0", "@inrupt/solid-client-errors": "^0.0.2", - "@inrupt/solid-client-vc": "^1.1.2", + "@inrupt/solid-client-vc": "^1.2.0", "@types/rdfjs__dataset": "^2.0.7", "auth-header": "^1.0.0", "base64url": "^3.0.1", From 7bdcc78cde5aa5dcc81a20e88a505cfe092a1f2d Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Tue, 17 Dec 2024 23:14:48 +0100 Subject: [PATCH 09/23] Add API docs --- src/gConsent/index.ts | 1 + src/gConsent/query/query.ts | 39 +++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + 3 files changed, 41 insertions(+) diff --git a/src/gConsent/index.ts b/src/gConsent/index.ts index 13f92662..c02b5f3e 100644 --- a/src/gConsent/index.ts +++ b/src/gConsent/index.ts @@ -50,6 +50,7 @@ export { CredentialResult, CredentialStatus, CredentialType, + DURATION, query, } from "./query/query"; diff --git a/src/gConsent/query/query.ts b/src/gConsent/query/query.ts index 226460ad..d33f5c83 100644 --- a/src/gConsent/query/query.ts +++ b/src/gConsent/query/query.ts @@ -40,6 +40,13 @@ export type CredentialType = | "SolidAccessGrant" | "SolidAccessDenial"; +export const DURATION = { + ONE_DAY: "P1D", + ONE_WEEK: "P7D", + ONE_MONTH: "P1M", + THREE_MONTHS: "P3M", +} as const; + export type CredentialFilter = { type?: CredentialType; status?: CredentialStatus; @@ -147,6 +154,38 @@ function toQueryUrl(endpoint: URL, filter: CredentialFilter): URL { return result; } +/** + * Query for Access Requests or Access Grants based on a given filter. + * + * @param filter the query filter + * @param options query options + * @returns a paginated set of Access Credential matching the given filter + * @since unreleased + * + * @example + * ``` + * // Get the first results page. + * const activeGrantsToday = await query( + * { + * type: "SolidAccessGrant", + * status: "Active", + * issuedWithin: DURATION.ONE_DAY, + * }, + * { + * fetch: session.fetch, + * queryEndpoint: config.queryEndpoint, + * }, + * ); + * // Get the next results page. + * const activeGrantsToday2 = await query( + * activeGrantsToday.next, + * { + * fetch: session.fetch, + * queryEndpoint: config.queryEndpoint, + * }, + * ); + * ``` + */ export async function query( filter: CredentialFilter, options: { diff --git a/src/index.ts b/src/index.ts index b3d25304..a1d0d1bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ export { CredentialStatus, CredentialType, denyAccessRequest, + DURATION, getAccessApiEndpoint, getAccessGrant, getAccessGrantAll, From 07e2d3107130546ad59637277d4e3da05c451ed9 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Tue, 17 Dec 2024 23:14:59 +0100 Subject: [PATCH 10/23] Improve e2e tests --- e2e/node/e2e.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/e2e/node/e2e.test.ts b/e2e/node/e2e.test.ts index 8dd73596..b2aead83 100644 --- a/e2e/node/e2e.test.ts +++ b/e2e/node/e2e.test.ts @@ -52,6 +52,7 @@ import * as sc from "@inrupt/solid-client"; import { custom } from "openid-client"; import type { AccessGrant, AccessRequest } from "../../src/index"; import { + DURATION, approveAccessRequest, createContainerInContainer, denyAccessRequest, @@ -1714,7 +1715,7 @@ describe(`End-to-end access grant tests for environment [${environment}] `, () = }, ); - describe.only("query endpoint", () => { + describe("query endpoint", () => { it("can navigate the paginated results", async () => { const allCredentialsPageOne = await query( { pageSize: 10 }, @@ -1739,8 +1740,8 @@ describe(`End-to-end access grant tests for environment [${environment}] `, () = expect(allCredentialsPageTwo.items).toHaveLength(10); }); - it("can filter based on credential type", async () => { - const allGrantsPageOne = await query( + it("can filter based on one or more criteria", async () => { + const onType = await query( { type: "SolidAccessGrant" }, { fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), @@ -1748,16 +1749,19 @@ describe(`End-to-end access grant tests for environment [${environment}] `, () = queryEndpoint: new URL("query", vcProvider), }, ); - expect(allGrantsPageOne.items).not.toHaveLength(0); - const allGrantsPageOne = await query( - { type: "SolidAccessGrant" }, + expect(onType.items).not.toHaveLength(0); + const onTypeAndStatus = await query( + { type: "SolidAccessGrant", status: "Active" }, { fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), // FIXME add query endpoint discovery check. queryEndpoint: new URL("query", vcProvider), }, ); - expect(allGrantsPageOne.items).not.toHaveLength(0); + expect(onTypeAndStatus.items).not.toHaveLength(0); + expect(onTypeAndStatus.items.length).toBeLessThanOrEqual( + onType.items.length, + ); }); }); }); From a604c21d04a216b3289d138d9e68933466e66132 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Tue, 17 Dec 2024 23:40:31 +0100 Subject: [PATCH 11/23] Lint and enrich API docs --- e2e/node/e2e.test.ts | 7 +++-- src/gConsent/query/query.ts | 54 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/e2e/node/e2e.test.ts b/e2e/node/e2e.test.ts index b2aead83..eebe5607 100644 --- a/e2e/node/e2e.test.ts +++ b/e2e/node/e2e.test.ts @@ -52,7 +52,6 @@ import * as sc from "@inrupt/solid-client"; import { custom } from "openid-client"; import type { AccessGrant, AccessRequest } from "../../src/index"; import { - DURATION, approveAccessRequest, createContainerInContainer, denyAccessRequest, @@ -1751,7 +1750,11 @@ describe(`End-to-end access grant tests for environment [${environment}] `, () = ); expect(onType.items).not.toHaveLength(0); const onTypeAndStatus = await query( - { type: "SolidAccessGrant", status: "Active" }, + { + type: "SolidAccessGrant", + status: "Active", + issuedWithin: DURATION.ONE_DAY, + }, { fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), // FIXME add query endpoint discovery check. diff --git a/src/gConsent/query/query.ts b/src/gConsent/query/query.ts index d33f5c83..32c4e61b 100644 --- a/src/gConsent/query/query.ts +++ b/src/gConsent/query/query.ts @@ -26,6 +26,9 @@ import type { DatasetWithId } from "@inrupt/solid-client-vc"; import { verifiableCredentialToDataset } from "@inrupt/solid-client-vc"; import type { UrlString } from "@inrupt/solid-client"; +/** + * Supported Access Credentials status + */ export type CredentialStatus = | "Pending" | "Denied" @@ -35,11 +38,17 @@ export type CredentialStatus = | "Active" | "Revoked"; +/** + * Supported Access Credential types + */ export type CredentialType = | "SolidAccessRequest" | "SolidAccessGrant" | "SolidAccessDenial"; +/** + * Supported durations for Access Credential filtering. + */ export const DURATION = { ONE_DAY: "P1D", ONE_WEEK: "P7D", @@ -48,15 +57,45 @@ export const DURATION = { } as const; export type CredentialFilter = { + /** + * The Access Credential type (e.g. Access Request, Access Grant...) + */ type?: CredentialType; + /** + * The Access Credential status (e.g. Active, Revoked...) + */ status?: CredentialStatus; + /** + * WebID of the Agent who issued the Access Credential. + */ fromAgent?: UrlString; + /** + * WebID of the Agent who is the Access Credential recipient. + */ toAgent?: UrlString; + /** + * URL of the resource featured in the Access Credential. + */ resource?: UrlString; + /** + * URL of the Access Credential purpose. + */ purpose?: UrlString; + /** + * Interval (expressed using ISO 8601) during which the Credential was issued. + */ issuedWithin?: "P1D" | "P7D" | "P1M" | "P3M"; + /** + * Interval (expressed using ISO 8601) during which the Credential was revoked. + */ revokedWithin?: "P1D" | "P7D" | "P1M" | "P3M"; + /** + * Number of items per page. + */ pageSize?: number; + /** + * Target page (for result pagination). + */ page?: string; }; @@ -85,10 +124,25 @@ const isSupportedFilterElement = (x: unknown): x is SupportedFilterElements => const PAGING_RELS = ["first", "last", "prev", "next"] as const; export type CredentialResult = { + /** + * Page of Access Credentials matching the query. + */ items: DatasetWithId[]; + /** + * First page of query results. + */ first?: CredentialFilter; + /** + * Previous page of query results. + */ prev?: CredentialFilter; + /** + * Next page of query results. + */ next?: CredentialFilter; + /** + * Last page of query results. + */ last?: CredentialFilter; }; From 1c2af167a6e3a0c1e60ccb7864196080971eadf9 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Wed, 18 Dec 2024 09:53:10 +0100 Subject: [PATCH 12/23] fixup! Improve e2e tests --- e2e/node/e2e.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/node/e2e.test.ts b/e2e/node/e2e.test.ts index eebe5607..0eaf187d 100644 --- a/e2e/node/e2e.test.ts +++ b/e2e/node/e2e.test.ts @@ -52,6 +52,7 @@ import * as sc from "@inrupt/solid-client"; import { custom } from "openid-client"; import type { AccessGrant, AccessRequest } from "../../src/index"; import { + DURATION, approveAccessRequest, createContainerInContainer, denyAccessRequest, From e80ce5e9fa28e21e5607ccf9805aad9cad7a0f54 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Wed, 18 Dec 2024 13:27:11 +0100 Subject: [PATCH 13/23] Add unit tests --- src/gConsent/query/query.test.ts | 150 +++++++++++++++++++++++++++++++ src/gConsent/query/query.ts | 8 +- 2 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 src/gConsent/query/query.test.ts diff --git a/src/gConsent/query/query.test.ts b/src/gConsent/query/query.test.ts new file mode 100644 index 00000000..06dbd7b5 --- /dev/null +++ b/src/gConsent/query/query.test.ts @@ -0,0 +1,150 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import { jest, it, describe, expect } from "@jest/globals"; +import { detail } from "rdf-namespaces/dist/fhir"; +import type { CredentialFilter } from "./query"; +import { query } from "./query"; +import { mockAccessGrantVc } from "../util/access.mock"; + +describe("query", () => { + it("throws on server errors", async () => { + await expect(() => + query( + {}, + { + queryEndpoint: new URL("https://vc.example.org/query"), + fetch: jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + title: "Some title", + detail: "Some details", + }), + { + status: 400, + }, + ), + ), + }, + ), + ).rejects.toThrow(); + }); + + it("builds query params out of the provided filter", async () => { + const mockedFetch = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + items: [], + }), + ), + ); + const filter: CredentialFilter = { + fromAgent: "https://example.org/from-some-agent", + issuedWithin: "P1D", + purpose: "https://example.org/some-purpose", + revokedWithin: "P1D", + toAgent: "https://example.org/to-some-agent", + resource: "https://example.org/some-resource", + type: "SolidAccessGrant", + status: "Active", + pageSize: 10, + page: "some-page", + }; + await query(filter, { + queryEndpoint: new URL("https://vc.example.org/query"), + fetch: mockedFetch, + }); + const expectedQueryParams = new URLSearchParams({ + ...filter, + pageSize: `${filter.pageSize}`, + }); + expect(mockedFetch.mock.calls[0][0].toString()).toBe( + `https://vc.example.org/query?${expectedQueryParams}`, + ); + }); + + it("ignores unknown filter elements", async () => { + const mockedFetch = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + items: [], + }), + ), + ); + const filter = { + unknownKey: "some value", + }; + await query(filter as CredentialFilter, { + queryEndpoint: new URL("https://vc.example.org/query"), + fetch: mockedFetch, + }); + expect(mockedFetch.mock.calls[0][0].toString()).toBe( + `https://vc.example.org/query`, + ); + }); + + it("parses the server's response Link headers", async () => { + const firstQueryParams = { + type: "SolidAccessGrant", + page: "323904ad", + status: "Active", + }; + const linkFirst = `; rel="first"`; + const nextQueryParams = { + type: "SolidAccessGrant", + page: "6af7d740", + status: "Active", + }; + const linkNext = `; rel="next"`; + const lastQueryParams = { + type: "SolidAccessGrant", + page: "35faea55", + status: "Active", + }; + const linkLast = `; rel="last"`; + const paginationHeaders = new Headers(); + paginationHeaders.append("Link", linkFirst); + paginationHeaders.append("Link", linkLast); + paginationHeaders.append("Link", linkNext); + const mockedGrant = await mockAccessGrantVc(); + const mockedFetch = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + items: [mockedGrant], + }), + { + headers: paginationHeaders, + }, + ), + ); + const result = await query( + {}, + { + queryEndpoint: new URL("https://vc.example.org/query"), + fetch: mockedFetch, + }, + ); + expect(result.first).toStrictEqual(firstQueryParams); + expect(result.next).toStrictEqual(nextQueryParams); + expect(result.last).toStrictEqual(lastQueryParams); + expect(result.prev).toBeUndefined(); + }); +}); diff --git a/src/gConsent/query/query.ts b/src/gConsent/query/query.ts index 32c4e61b..cb87fd3a 100644 --- a/src/gConsent/query/query.ts +++ b/src/gConsent/query/query.ts @@ -203,7 +203,9 @@ async function toCredentialResult( function toQueryUrl(endpoint: URL, filter: CredentialFilter): URL { const result = new URL(endpoint); Object.entries(filter).forEach(([key, value]) => { - result.searchParams.append(key, value.toString()); + if (isSupportedFilterElement(key)) { + result.searchParams.append(key, value.toString()); + } }); return result; } @@ -243,12 +245,12 @@ function toQueryUrl(endpoint: URL, filter: CredentialFilter): URL { export async function query( filter: CredentialFilter, options: { - fetch?: typeof fetch; + fetch: typeof fetch; queryEndpoint: URL; }, ): Promise { const queryUrl = toQueryUrl(options.queryEndpoint, filter); - const response = await (options.fetch ?? fetch)(queryUrl); + const response = await options.fetch(queryUrl); if (!response.ok) { throw handleErrorResponse( response, From 47456d5e1c3c34e175c0fc71113e5f73470237db Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Wed, 18 Dec 2024 13:36:53 +0100 Subject: [PATCH 14/23] fixup! Add unit tests --- src/gConsent/query/query.test.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/gConsent/query/query.test.ts b/src/gConsent/query/query.test.ts index 06dbd7b5..ea3e79a0 100644 --- a/src/gConsent/query/query.test.ts +++ b/src/gConsent/query/query.test.ts @@ -22,7 +22,7 @@ import { jest, it, describe, expect } from "@jest/globals"; import { detail } from "rdf-namespaces/dist/fhir"; import type { CredentialFilter } from "./query"; -import { query } from "./query"; +import { DURATION, query } from "./query"; import { mockAccessGrantVc } from "../util/access.mock"; describe("query", () => { @@ -147,4 +147,24 @@ describe("query", () => { expect(result.last).toStrictEqual(lastQueryParams); expect(result.prev).toBeUndefined(); }); + + it("exposes utility constants for duration", async () => { + const mockedFetch = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + items: [], + }), + ), + ); + await query( + { issuedWithin: DURATION.ONE_DAY, revokedWithin: DURATION.ONE_WEEK }, + { + queryEndpoint: new URL("https://vc.example.org/query"), + fetch: mockedFetch, + }, + ); + expect(mockedFetch.mock.calls[0][0].toString()).toBe( + `https://vc.example.org/query?issuedWithin=P1D&revokedWithin=P7D`, + ); + }); }); From 92548af92878973c845c56c58bb93f1f777bb5f9 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Wed, 18 Dec 2024 13:41:08 +0100 Subject: [PATCH 15/23] Deprecate getAccessGrantAll --- src/gConsent/manage/getAccessGrantAll.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/gConsent/manage/getAccessGrantAll.ts b/src/gConsent/manage/getAccessGrantAll.ts index ce87c7b6..21ec8f64 100644 --- a/src/gConsent/manage/getAccessGrantAll.ts +++ b/src/gConsent/manage/getAccessGrantAll.ts @@ -126,6 +126,7 @@ const getAncestorUrls = (resourceUrl: URL) => { * with the environment you are requesting against. * @returns A promise resolving to an array of Access Grants matching the request. * @since 0.4.0 + * @deprecated Use the new `query` method instead. */ async function getAccessGrantAll( params: AccessParameters, @@ -134,7 +135,7 @@ async function getAccessGrantAll( }, ): Promise>; /** - * @deprecated Please set returnLegacyJsonld: false and use RDFJS API + * @deprecated Use the new `query` method instead. */ async function getAccessGrantAll( params: AccessParameters, @@ -143,7 +144,7 @@ async function getAccessGrantAll( }, ): Promise>; /** - * @deprecated Please set returnLegacyJsonld: false and use RDFJS API + * @deprecated Use the new `query` method instead. */ async function getAccessGrantAll( params: AccessParameters, From f00cec746c093e0eedd5eab6f4841100a47986df Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Wed, 18 Dec 2024 13:46:40 +0100 Subject: [PATCH 16/23] Add named exports --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index 87fac953..5742583a 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "./fetch": "./dist/resource/index.mjs", "./discover": "./dist/gConsent/discover/index.mjs", "./manage": "./dist/gConsent/manage/index.mjs", + "./query": "./dist/gConsent/query/query.mjs", "./request": "./dist/gConsent/request/index.mjs", "./verify": "./dist/common/verify/index.mjs", "./getters": "./dist/common/getters.mjs", @@ -61,6 +62,9 @@ "manage": [ "dist/gConsent/manage/index.d.ts" ], + "query": [ + "dist/gConsent/query/query.d.ts" + ], "request": [ "dist/gConsent/request/index.d.ts" ], From 907ca08d8d7a4b4ac97147b9edda0f85911717d5 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Wed, 18 Dec 2024 13:50:03 +0100 Subject: [PATCH 17/23] Update changelog --- CHANGELOG.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 959ec819..b7842c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,17 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html The following changes are pending, and will be applied on the next major release: -- The `status` parameter for `getAccessGrantAll` will default to `all` rather than `granted`. +- The `getAccessGrantAll` function is deprecated. It can be replaced with `query`. + Although the two functions behavior is different, they can be used to achieve + similar results. +- The `gConsent` and all of `gConsent/*` submodules are deprecated. The former can + be replaced by a regular import of the library, and the latters can be replaced + with the equivalent non-gConsent submodules (e.g. `gConsent/manage` can be replaced + with `manage`). There is no functionality change between the two. ## Unreleased -### New feature (alpha) +### New feature - Add support for custom fields. Applications are now able to read and write custom fields into Access Credentials (both Access Requests and Access Grants). This feature is available @@ -18,6 +24,8 @@ The following changes are pending, and will be applied on the next major release custom fields, and via a set of dedicated getters in the `getters/` module. A generic getter is introduced, `getCustomFields`, as well as a set of typed helpers, such as `getCustomInteger`. Typed helpers are available for integers, floats, strings and booleans. +- Support new query endpoint: the new `query` function allows to query for Access Credentials using + the newly introduce ESS endpoint. ## [3.1.1](https://github.com/inrupt/solid-client-access-grants-js/releases/tag/v3.1.1) - 2024-10-23 From 5b6792b1735e6dab8deb33fab7866f416c91f3e4 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Wed, 18 Dec 2024 13:54:06 +0100 Subject: [PATCH 18/23] Lint --- src/gConsent/query/query.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/gConsent/query/query.test.ts b/src/gConsent/query/query.test.ts index ea3e79a0..fa03c508 100644 --- a/src/gConsent/query/query.test.ts +++ b/src/gConsent/query/query.test.ts @@ -20,7 +20,6 @@ // import { jest, it, describe, expect } from "@jest/globals"; -import { detail } from "rdf-namespaces/dist/fhir"; import type { CredentialFilter } from "./query"; import { DURATION, query } from "./query"; import { mockAccessGrantVc } from "../util/access.mock"; From 0ff612ce75816fd44500ed3ecf0e63121cde06ab Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Wed, 18 Dec 2024 14:44:28 +0100 Subject: [PATCH 19/23] Add feature flag --- .github/workflows/e2e-node.yml | 1 + e2e/node/e2e.test.ts | 99 +++++++++++++++++----------------- 2 files changed, 52 insertions(+), 48 deletions(-) diff --git a/.github/workflows/e2e-node.yml b/.github/workflows/e2e-node.yml index 2f50a7a9..f7da31f1 100644 --- a/.github/workflows/e2e-node.yml +++ b/.github/workflows/e2e-node.yml @@ -50,3 +50,4 @@ jobs: E2E_TEST_OWNER_CLIENT_ID: ${{ secrets.E2E_TEST_OWNER_CLIENT_ID }} E2E_TEST_OWNER_CLIENT_SECRET: ${{ secrets.E2E_TEST_OWNER_CLIENT_SECRET }} E2E_TEST_FEATURE_RECURSIVE_ACCESS_GRANTS: ${{ secrets.E2E_TEST_FEATURE_RECURSIVE_ACCESS_GRANTS }} + E2E_TEST_FEATURE_QUERY_ENDPOINT: ${{ secrets.E2E_TEST_FEATURE_QUERY_ENDPOINT }} diff --git a/e2e/node/e2e.test.ts b/e2e/node/e2e.test.ts index 0eaf187d..58934b10 100644 --- a/e2e/node/e2e.test.ts +++ b/e2e/node/e2e.test.ts @@ -1715,57 +1715,60 @@ describe(`End-to-end access grant tests for environment [${environment}] `, () = }, ); - describe("query endpoint", () => { - it("can navigate the paginated results", async () => { - const allCredentialsPageOne = await query( - { pageSize: 10 }, - { + describeIf(environmentFeatures?.QUERY_ENDPOINT === "true")( + "query endpoint", + () => { + it("can navigate the paginated results", async () => { + const allCredentialsPageOne = await query( + { pageSize: 10 }, + { + fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), + // FIXME add query endpoint discovery check. + queryEndpoint: new URL("query", vcProvider), + }, + ); + // We should get the expected page length. + expect(allCredentialsPageOne.items).toHaveLength(10); + // The first page should not have a "prev" link. + expect(allCredentialsPageOne.prev).toBeUndefined(); + expect(allCredentialsPageOne.next).toBeDefined(); + + // Go to the next result page + const allCredentialsPageTwo = await query(allCredentialsPageOne.next!, { fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), // FIXME add query endpoint discovery check. queryEndpoint: new URL("query", vcProvider), - }, - ); - // We should get the expected page length. - expect(allCredentialsPageOne.items).toHaveLength(10); - // The first page should not have a "prev" link. - expect(allCredentialsPageOne.prev).toBeUndefined(); - expect(allCredentialsPageOne.next).toBeDefined(); - - // Go to the next result page - const allCredentialsPageTwo = await query(allCredentialsPageOne.next!, { - fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), - // FIXME add query endpoint discovery check. - queryEndpoint: new URL("query", vcProvider), + }); + expect(allCredentialsPageTwo.items).toHaveLength(10); }); - expect(allCredentialsPageTwo.items).toHaveLength(10); - }); - it("can filter based on one or more criteria", async () => { - const onType = await query( - { type: "SolidAccessGrant" }, - { - fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), - // FIXME add query endpoint discovery check. - queryEndpoint: new URL("query", vcProvider), - }, - ); - expect(onType.items).not.toHaveLength(0); - const onTypeAndStatus = await query( - { - type: "SolidAccessGrant", - status: "Active", - issuedWithin: DURATION.ONE_DAY, - }, - { - fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), - // FIXME add query endpoint discovery check. - queryEndpoint: new URL("query", vcProvider), - }, - ); - expect(onTypeAndStatus.items).not.toHaveLength(0); - expect(onTypeAndStatus.items.length).toBeLessThanOrEqual( - onType.items.length, - ); - }); - }); + it("can filter based on one or more criteria", async () => { + const onType = await query( + { type: "SolidAccessGrant" }, + { + fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), + // FIXME add query endpoint discovery check. + queryEndpoint: new URL("query", vcProvider), + }, + ); + expect(onType.items).not.toHaveLength(0); + const onTypeAndStatus = await query( + { + type: "SolidAccessGrant", + status: "Active", + issuedWithin: DURATION.ONE_DAY, + }, + { + fetch: addUserAgent(requestorSession.fetch, TEST_USER_AGENT), + // FIXME add query endpoint discovery check. + queryEndpoint: new URL("query", vcProvider), + }, + ); + expect(onTypeAndStatus.items).not.toHaveLength(0); + expect(onTypeAndStatus.items.length).toBeLessThanOrEqual( + onType.items.length, + ); + }); + }, + ); }); From 3b9b61c1ec10f9a5bbb65e5470258f0302b51836 Mon Sep 17 00:00:00 2001 From: Zwifi Date: Thu, 19 Dec 2024 12:53:59 +0100 Subject: [PATCH 20/23] Apply suggestions from code review Co-authored-by: Pete Edwards --- CHANGELOG.md | 6 +++--- src/gConsent/query/query.ts | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7842c58..42ef3129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The following changes are pending, and will be applied on the next major release Although the two functions behavior is different, they can be used to achieve similar results. - The `gConsent` and all of `gConsent/*` submodules are deprecated. The former can - be replaced by a regular import of the library, and the latters can be replaced + be replaced by a regular import of the library, and the latter can be replaced with the equivalent non-gConsent submodules (e.g. `gConsent/manage` can be replaced with `manage`). There is no functionality change between the two. @@ -24,8 +24,8 @@ The following changes are pending, and will be applied on the next major release custom fields, and via a set of dedicated getters in the `getters/` module. A generic getter is introduced, `getCustomFields`, as well as a set of typed helpers, such as `getCustomInteger`. Typed helpers are available for integers, floats, strings and booleans. -- Support new query endpoint: the new `query` function allows to query for Access Credentials using - the newly introduce ESS endpoint. +- Support new query endpoint: the new `query` function enables querying for Access Credentials using + the newly introduced ESS endpoint. ## [3.1.1](https://github.com/inrupt/solid-client-access-grants-js/releases/tag/v3.1.1) - 2024-10-23 diff --git a/src/gConsent/query/query.ts b/src/gConsent/query/query.ts index cb87fd3a..24a1285d 100644 --- a/src/gConsent/query/query.ts +++ b/src/gConsent/query/query.ts @@ -27,7 +27,7 @@ import { verifiableCredentialToDataset } from "@inrupt/solid-client-vc"; import type { UrlString } from "@inrupt/solid-client"; /** - * Supported Access Credentials status + * Supported Access Credential statuses */ export type CredentialStatus = | "Pending" @@ -58,11 +58,11 @@ export const DURATION = { export type CredentialFilter = { /** - * The Access Credential type (e.g. Access Request, Access Grant...) + * The Access Credential type (e.g. Access Request, Access Grant...). */ type?: CredentialType; /** - * The Access Credential status (e.g. Active, Revoked...) + * The Access Credential status (e.g. Active, Revoked...). */ status?: CredentialStatus; /** @@ -82,11 +82,11 @@ export type CredentialFilter = { */ purpose?: UrlString; /** - * Interval (expressed using ISO 8601) during which the Credential was issued. + * Period (expressed using ISO 8601) during which the Credential was issued. */ issuedWithin?: "P1D" | "P7D" | "P1M" | "P3M"; /** - * Interval (expressed using ISO 8601) during which the Credential was revoked. + * Period (expressed using ISO 8601) during which the Credential was revoked. */ revokedWithin?: "P1D" | "P7D" | "P1M" | "P3M"; /** @@ -177,7 +177,7 @@ async function toCredentialResult( const link = parsedLinks.get("rel", rel); if (link.length > 1) { throw Error( - `Unexpected response, found more than one ${rel} Link headers.`, + `Unexpected response, found more than one [${rel}] Link headers.`, ); } if (link.length === 0) { @@ -213,15 +213,15 @@ function toQueryUrl(endpoint: URL, filter: CredentialFilter): URL { /** * Query for Access Requests or Access Grants based on a given filter. * - * @param filter the query filter - * @param options query options + * @param filter The query filter + * @param options Query options * @returns a paginated set of Access Credential matching the given filter * @since unreleased * * @example * ``` * // Get the first results page. - * const activeGrantsToday = await query( + * const activeGrantsWithinDay = await query( * { * type: "SolidAccessGrant", * status: "Active", @@ -233,8 +233,8 @@ function toQueryUrl(endpoint: URL, filter: CredentialFilter): URL { * }, * ); * // Get the next results page. - * const activeGrantsToday2 = await query( - * activeGrantsToday.next, + * const activeGrantsWithinDay2 = await query( + * activeGrantsWithinDay.next, * { * fetch: session.fetch, * queryEndpoint: config.queryEndpoint, From 87c5e87e8d16028db4f11d0dc54ae488704ad08b Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Thu, 19 Dec 2024 13:40:59 +0100 Subject: [PATCH 21/23] Refactor response parsing --- src/gConsent/query/query.test.ts | 38 +++++++++++++++++++++++++++++ src/gConsent/query/query.ts | 42 +++++++++++++++++--------------- tsconfig.json | 4 +-- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/gConsent/query/query.test.ts b/src/gConsent/query/query.test.ts index fa03c508..1cb09b43 100644 --- a/src/gConsent/query/query.test.ts +++ b/src/gConsent/query/query.test.ts @@ -166,4 +166,42 @@ describe("query", () => { `https://vc.example.org/query?issuedWithin=P1D&revokedWithin=P7D`, ); }); + + it("throws if no 'items' array is present in the response", async () => { + const mockedFetch = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + notAnItemsArray: "some value", + }), + ), + ); + await expect( + query( + {}, + { + queryEndpoint: new URL("https://vc.example.org/query"), + fetch: mockedFetch, + }, + ), + ).rejects.toThrow(); + }); + + it("throws if invalid items are present in the response", async () => { + const mockedFetch = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + items: ["not a VC"], + }), + ), + ); + await expect( + query( + {}, + { + queryEndpoint: new URL("https://vc.example.org/query"), + fetch: mockedFetch, + }, + ), + ).rejects.toThrow(); + }); }); diff --git a/src/gConsent/query/query.ts b/src/gConsent/query/query.ts index 24a1285d..4e37c93f 100644 --- a/src/gConsent/query/query.ts +++ b/src/gConsent/query/query.ts @@ -25,6 +25,7 @@ import { handleErrorResponse } from "@inrupt/solid-client-errors"; import type { DatasetWithId } from "@inrupt/solid-client-vc"; import { verifiableCredentialToDataset } from "@inrupt/solid-client-vc"; import type { UrlString } from "@inrupt/solid-client"; +import { AccessGrantError } from "../../common/errors/AccessGrantError"; /** * Supported Access Credential statuses @@ -156,14 +157,27 @@ function toCredentialFilter(url: URL): CredentialFilter { return filter; } -function hasItems(x: unknown): x is { items: unknown[] } { - const candidate = x as { items: unknown }; - return candidate.items !== undefined && Array.isArray(candidate.items); -} - -function isObjectWithId(x: unknown): x is { id: string } { - const candidate = x as { id: string }; - return typeof candidate.id === "string"; +async function parseQueryResponse( + responseJson: unknown, +): Promise { + const candidate = responseJson as { items: unknown }; + if (candidate.items === undefined || !Array.isArray(candidate.items)) { + throw new AccessGrantError( + `Unexpected query response, no 'items' array found in ${JSON.stringify(responseJson)}`, + ); + } + return Promise.all( + candidate.items.map((vc) => { + return verifiableCredentialToDataset(vc); + }), + ).catch((error) => { + throw new AccessGrantError( + `Unexpected query response, parsing the content of the 'items' array failed.`, + { + cause: error, + }, + ); + }); } async function toCredentialResult( @@ -186,17 +200,7 @@ async function toCredentialResult( result[rel] = toCredentialFilter(new URL(link[0].uri)); }); } - const body = await response.json(); - if (!hasItems(body) || !body.items.every(isObjectWithId)) { - throw Error( - `Unexpected response, no items found in ${JSON.stringify(body)}`, - ); - } - result.items = await Promise.all( - body.items.map((vc) => { - return verifiableCredentialToDataset(vc); - }), - ); + result.items = await parseQueryResponse(await response.json()); return result; } diff --git a/tsconfig.json b/tsconfig.json index 0c67f74a..8ab6f9be 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { "compilerOptions": { /* Basic Options */ - "target": "ES2018", + "target": "ES2022", "module": "commonjs", "moduleResolution": "node", - "lib": ["es2018", "dom"], + "lib": ["es2022", "dom"], "declaration": true, "outDir": "dist", "rootDir": "src", From 7d323f9f0751b3a8b4495891ffbbd689fb0915d6 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Thu, 19 Dec 2024 13:49:50 +0100 Subject: [PATCH 22/23] Apply review suggestions --- src/gConsent/query/query.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/gConsent/query/query.ts b/src/gConsent/query/query.ts index 4e37c93f..1cbd789e 100644 --- a/src/gConsent/query/query.ts +++ b/src/gConsent/query/query.ts @@ -160,6 +160,7 @@ function toCredentialFilter(url: URL): CredentialFilter { async function parseQueryResponse( responseJson: unknown, ): Promise { + // The type assertion here is immediatly checked. const candidate = responseJson as { items: unknown }; if (candidate.items === undefined || !Array.isArray(candidate.items)) { throw new AccessGrantError( @@ -215,11 +216,11 @@ function toQueryUrl(endpoint: URL, filter: CredentialFilter): URL { } /** - * Query for Access Requests or Access Grants based on a given filter. + * Query for Access Credential (Access Requests, Access Grants or Access Denials) based on a given filter. * * @param filter The query filter * @param options Query options - * @returns a paginated set of Access Credential matching the given filter + * @returns a paginated set of matching the given filter * @since unreleased * * @example From e32a4f4ef9e6673d2accc7b5af10025576f2ea91 Mon Sep 17 00:00:00 2001 From: Zwifi Date: Thu, 19 Dec 2024 17:11:03 +0100 Subject: [PATCH 23/23] Apply suggestions from code review Co-authored-by: Pete Edwards --- CHANGELOG.md | 2 +- src/gConsent/query/query.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ef3129..8603378e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html The following changes are pending, and will be applied on the next major release: - The `getAccessGrantAll` function is deprecated. It can be replaced with `query`. - Although the two functions behavior is different, they can be used to achieve + Although the two functions' behavior is different, they can be used to achieve similar results. - The `gConsent` and all of `gConsent/*` submodules are deprecated. The former can be replaced by a regular import of the library, and the latter can be replaced diff --git a/src/gConsent/query/query.ts b/src/gConsent/query/query.ts index 1cbd789e..f6f90d2e 100644 --- a/src/gConsent/query/query.ts +++ b/src/gConsent/query/query.ts @@ -160,7 +160,7 @@ function toCredentialFilter(url: URL): CredentialFilter { async function parseQueryResponse( responseJson: unknown, ): Promise { - // The type assertion here is immediatly checked. + // The type assertion here is immediately checked. const candidate = responseJson as { items: unknown }; if (candidate.items === undefined || !Array.isArray(candidate.items)) { throw new AccessGrantError( @@ -216,11 +216,11 @@ function toQueryUrl(endpoint: URL, filter: CredentialFilter): URL { } /** - * Query for Access Credential (Access Requests, Access Grants or Access Denials) based on a given filter. + * Query for Access Credentials (Access Requests, Access Grants or Access Denials) based on a given filter. * * @param filter The query filter * @param options Query options - * @returns a paginated set of matching the given filter + * @returns a paginated set of Access Credentials matching the given filter * @since unreleased * * @example