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/CHANGELOG.md b/CHANGELOG.md index 959ec819..8603378e 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 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. ## 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 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/e2e/node/e2e.test.ts b/e2e/node/e2e.test.ts index e4ad1163..58934b10 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, @@ -71,6 +72,7 @@ import { isValidAccessGrant, issueAccessRequest, overwriteFile, + query, revokeAccessGrant, saveFileInContainer, saveSolidDatasetAt, @@ -1712,4 +1714,61 @@ describe(`End-to-end access grant tests for environment [${environment}] `, () = }); }, ); + + 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), + }); + 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, + ); + }); + }, + ); }); 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..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" ], @@ -131,7 +135,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", diff --git a/src/gConsent/index.ts b/src/gConsent/index.ts index fb76e150..c02b5f3e 100644 --- a/src/gConsent/index.ts +++ b/src/gConsent/index.ts @@ -45,6 +45,15 @@ export { getAccessGrantFromRedirectUrl, } from "./request"; +export { + CredentialFilter, + CredentialResult, + CredentialStatus, + CredentialType, + DURATION, + query, +} from "./query/query"; + export { approveAccessRequest, denyAccessRequest, 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, diff --git a/src/gConsent/query/query.test.ts b/src/gConsent/query/query.test.ts new file mode 100644 index 00000000..1cb09b43 --- /dev/null +++ b/src/gConsent/query/query.test.ts @@ -0,0 +1,207 @@ +// +// 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 type { CredentialFilter } from "./query"; +import { DURATION, 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(); + }); + + 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`, + ); + }); + + 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 new file mode 100644 index 00000000..f6f90d2e --- /dev/null +++ b/src/gConsent/query/query.ts @@ -0,0 +1,267 @@ +// +// 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 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 + */ +export type CredentialStatus = + | "Pending" + | "Denied" + | "Granted" + | "Canceled" + | "Expired" + | "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", + ONE_MONTH: "P1M", + THREE_MONTHS: "P3M", +} 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; + /** + * Period (expressed using ISO 8601) during which the Credential was issued. + */ + issuedWithin?: "P1D" | "P7D" | "P1M" | "P3M"; + /** + * Period (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; +}; + +const FILTER_ELEMENTS: Array = [ + "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; + +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; +}; + +function toCredentialFilter(url: URL): CredentialFilter { + const filter: CredentialFilter = {}; + url.searchParams.forEach((value, key) => { + if (isSupportedFilterElement(key)) { + Object.assign(filter, { [`${key}`]: value }); + } + }); + return filter; +} + +async function parseQueryResponse( + responseJson: unknown, +): Promise { + // The type assertion here is immediately checked. + 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( + response: Response, +): Promise { + 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.`, + ); + } + if (link.length === 0) { + return; + } + result[rel] = toCredentialFilter(new URL(link[0].uri)); + }); + } + result.items = await parseQueryResponse(await response.json()); + return result; +} + +function toQueryUrl(endpoint: URL, filter: CredentialFilter): URL { + const result = new URL(endpoint); + Object.entries(filter).forEach(([key, value]) => { + if (isSupportedFilterElement(key)) { + result.searchParams.append(key, value.toString()); + } + }); + return result; +} + +/** + * 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 Access Credentials matching the given filter + * @since unreleased + * + * @example + * ``` + * // Get the first results page. + * const activeGrantsWithinDay = await query( + * { + * type: "SolidAccessGrant", + * status: "Active", + * issuedWithin: DURATION.ONE_DAY, + * }, + * { + * fetch: session.fetch, + * queryEndpoint: config.queryEndpoint, + * }, + * ); + * // Get the next results page. + * const activeGrantsWithinDay2 = await query( + * activeGrantsWithinDay.next, + * { + * fetch: session.fetch, + * queryEndpoint: config.queryEndpoint, + * }, + * ); + * ``` + */ +export async function query( + filter: CredentialFilter, + options: { + fetch: typeof fetch; + queryEndpoint: URL; + }, +): Promise { + const queryUrl = toQueryUrl(options.queryEndpoint, filter); + const response = await options.fetch(queryUrl); + if (!response.ok) { + throw handleErrorResponse( + response, + await response.text(), + `The query endpoint [${queryUrl}] returned an error`, + ); + } + return toCredentialResult(response); +} diff --git a/src/index.ts b/src/index.ts index d9e51d1b..a1d0d1bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,7 +46,12 @@ export { CredentialIsAccessGrantAny } from "./type/AccessGrant"; export { approveAccessRequest, cancelAccessRequest, + CredentialFilter, + CredentialResult, + CredentialStatus, + CredentialType, denyAccessRequest, + DURATION, getAccessApiEndpoint, getAccessGrant, getAccessGrantAll, @@ -55,6 +60,7 @@ export { getAccessManagementUi, getAccessRequestFromRedirectUrl, issueAccessRequest, + query, redirectToAccessManagementUi, redirectToRequestor, revokeAccessGrant, 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",