diff --git a/src/common/getters.test.ts b/src/common/getters.test.ts index c48bcab2..f23fdcd9 100644 --- a/src/common/getters.test.ts +++ b/src/common/getters.test.ts @@ -628,14 +628,14 @@ describe("getters", () => { }); describe("getCustomIntegers", () => { - it("gets an integer array value from an access request custom field", async () => { + it("gets an integer array value from an access grant custom field", async () => { const customFields = [ { key: new URL("https://example.org/ns/customInt"), value: [1], }, ]; - const gConsentRequest = await mockGConsentRequest({ + const gConsentRequest = await mockGConsentGrant({ custom: customFields, }); // This shows the typing of the return is correct. @@ -716,6 +716,24 @@ describe("getters", () => { expect(b).toEqual([true]); }); + it("does not collapse similar values", async () => { + const customFields = [ + { + key: new URL("https://example.org/ns/customBoolean"), + value: [true, true, true], + }, + ]; + const gConsentRequest = await mockGConsentRequest({ + custom: customFields, + }); + // This shows the typing of the return is correct. + const b: boolean[] = getCustomBooleans( + gConsentRequest, + new URL("https://example.org/ns/customBoolean"), + ); + expect(b).toEqual([true, true, true]); + }); + it("throws on mixed types", async () => { const customFields = [ { @@ -768,6 +786,24 @@ describe("getters", () => { expect(d).toEqual([1.1]); }); + it("does not collapse similar values", async () => { + const customFields = [ + { + key: new URL("https://example.org/ns/customFloat"), + value: [1.1, 1.1, 1.1], + }, + ]; + const gConsentRequest = await mockGConsentRequest({ + custom: customFields, + }); + // This shows the typing of the return is correct. + const d: number[] = getCustomDoubles( + gConsentRequest, + new URL("https://example.org/ns/customFloat"), + ); + expect(d).toEqual([1.1, 1.1, 1.1]); + }); + it("throws on mixed types", async () => { const customFields = [ { @@ -820,6 +856,24 @@ describe("getters", () => { expect(s).toEqual(["custom value"]); }); + it("does not collapse similar values", async () => { + const customFields = [ + { + key: new URL("https://example.org/ns/customString"), + value: ["some value", "some value", "some value"], + }, + ]; + const gConsentRequest = await mockGConsentRequest({ + custom: customFields, + }); + // This shows the typing of the return is correct. + const s: string[] = getCustomStrings( + gConsentRequest, + new URL("https://example.org/ns/customString"), + ); + expect(s).toEqual(["some value", "some value", "some value"]); + }); + it("throws on mixed types", async () => { const customFields = [ { diff --git a/src/common/getters.ts b/src/common/getters.ts index d74060a4..3dd7ffd5 100644 --- a/src/common/getters.ts +++ b/src/common/getters.ts @@ -442,6 +442,79 @@ export function getInherit(vc: DatasetWithId): boolean { ); } +const WELL_KNOWN_KEYS = [ + "forPersonalData", + "forPurpose", + "isProvidedTo", + "isProvidedToController", + "isProvidedToPerson", + "mode", + "inherit", + "hasStatus", + "isConsentForDataSubject", + gc.forPersonalData.value, + gc.forPurpose.value, + gc.isProvidedTo.value, + gc.isProvidedToController.value, + gc.isProvidedToPerson.value, + gc.hasStatus.value, + gc.isConsentForDataSubject.value, + acl.mode.value, + INHERIT.value, +] as const; + +/** + * Reads all the custom fields in the consent section of the provided Access Credential. + * + * @example + * ``` + * const accessRequest = await issueAccessRequest({...}, { + * ..., + * customFields: new Set([ + * { + * key: new URL("https://example.org/ns/customString"), + * value: "custom value", + * }, + * { + * key: new URL("https://example.org/ns/customInteger"), + * value: 1, + * }, + * ]), + * }); + * const customFields = getCustomFields(accessRequest); + * // s is "custom value" + * const s = customFields["https://example.org/ns/customString"]; + * // i is 1 + * const i = customFields["https://example.org/ns/custominteger"]; + * ``` + * + * @param accessCredential The Access Credential (Access Grant or Access Request) + * @returns an object keyed by the custom fields names, associated to their values. + * @since unreleased + */ +export function getCustomFields( + accessCredential: DatasetWithId, +): Record { + const credentialObject = JSON.parse(JSON.stringify(accessCredential)); + let consent; + if (isAccessGrant(credentialObject)) { + consent = (credentialObject as AccessGrant).credentialSubject + .providedConsent; + } else if (isAccessRequest(credentialObject)) { + consent = (credentialObject as AccessRequest).credentialSubject.hasConsent; + } + return ( + Object.entries(consent ?? {}) + // Filter out all well-known keys + .filter((entry) => !WELL_KNOWN_KEYS.includes(entry[0])) + .reduce( + (customFields, curEntry) => + Object.assign(customFields, { [`${curEntry[0]}`]: curEntry[1] }), + {}, + ) + ); +} + /** * Internal function. Deserializes a literal using the provided function. * If the literal cannot be deserialized as expected (e.g. a string attempted @@ -452,40 +525,33 @@ export function getInherit(vc: DatasetWithId): boolean { function deserializeFields( vc: DatasetWithId, field: URL, - deserializer: (value: Literal) => T | undefined, + validator: (value: unknown) => value is T, type: string, -): NonNullable[] { - return Array.from( - vc.match(getConsent(vc), namedNode(field.href), null, defaultGraph()), - ) - .map((q) => { - if (q.object.termType !== "Literal") { - throw new Error( - `Expected value object for predicate ${field.href} to be a litteral, found ${q.object.termType}.`, - ); - } - return q.object as Literal; - }) - .map((object) => { - const result = deserializer(object); - if (result === undefined) { - // FIXME use inrupt error library - throw new Error( - `Error deserializing value ${object} for predicate ${field.href} as type ${type}.`, - ); - } - return result; - // FIXME why isn't TS happy about the filtering out of undefined? - }) as NonNullable[]; +): T[] { + const customFields = getCustomFields(vc); + const foundValue = customFields[field.href]; + if (foundValue === undefined) { + return []; + } + if (validator(foundValue)) { + return [foundValue]; + } + if (Array.isArray(foundValue) && foundValue.every(validator)) { + return foundValue; + } + + throw new Error( + `Could not interpret value for predicate ${field.href} as ${type}, found: ${foundValue}`, + ); } function deserializeField( vc: DatasetWithId, field: URL, - deserializer: (value: Literal) => T | undefined, + validator: (value: unknown) => value is T, type: string, ): T | undefined { - const result = deserializeFields(vc, field, deserializer, type); + const result = deserializeFields(vc, field, validator, type); if (result.length > 1) { // FIXME use inrupt error library throw new Error( @@ -495,26 +561,6 @@ function deserializeField( return result[0]; } -const xmlSchemaTypes = { - boolean: namedNode("http://www.w3.org/2001/XMLSchema#boolean"), - double: namedNode("http://www.w3.org/2001/XMLSchema#double"), - integer: namedNode("http://www.w3.org/2001/XMLSchema#integer"), - string: namedNode("http://www.w3.org/2001/XMLSchema#string"), -} as const; - -function deserializeBoolean(serialized: Literal): boolean | undefined { - if (!serialized.datatype.equals(xmlSchemaTypes.boolean)) { - return undefined; - } - if (serialized.value === "true") { - return true; - } - if (serialized.value === "false") { - return false; - } - return undefined; -} - /** * Reads the custom boolean value with the provided name in the consent section of the provided Access Credential. * @@ -544,7 +590,7 @@ export function getCustomBooleans( return deserializeFields( accessCredential, field, - deserializeBoolean, + (b) => typeof b === "boolean", "boolean", ); } @@ -578,19 +624,11 @@ export function getCustomBoolean( return deserializeField( accessCredential, field, - deserializeBoolean, + (b) => typeof b === "boolean", "boolean", ); } -function deserizalizeDouble(serialized: Literal): number | undefined { - if (!serialized.datatype.equals(xmlSchemaTypes.double)) { - return undefined; - } - const val = Number.parseFloat(serialized.value); - return Number.isNaN(val) ? undefined : val; -} - /** * Reads the custom double array value with the provided name in the consent section of the provided Access Credential. * @@ -620,7 +658,7 @@ export function getCustomDoubles( return deserializeFields( accessCredential, field, - deserizalizeDouble, + (d: unknown): d is number => typeof d === "number" && !Number.isInteger(d), "double", ); } @@ -654,19 +692,11 @@ export function getCustomDouble( return deserializeField( accessCredential, field, - deserizalizeDouble, + (d: unknown): d is number => typeof d === "number" && !Number.isInteger(d), "double", ); } -function deserizalizeInteger(serialized: Literal): number | undefined { - if (!serialized.datatype.equals(xmlSchemaTypes.integer)) { - return undefined; - } - const val = Number.parseInt(serialized.value, 10); - return Number.isNaN(val) ? undefined : val; -} - /** * Reads the custom integer array value with the provided name in the consent section of the provided Access Credential. * @@ -696,7 +726,7 @@ export function getCustomIntegers( return deserializeFields( accessCredential, field, - deserizalizeInteger, + (i: unknown): i is number => typeof i === "number" && Number.isInteger(i), "integer", ); } @@ -730,7 +760,7 @@ export function getCustomInteger( return deserializeField( accessCredential, field, - deserizalizeInteger, + (i: unknown): i is number => typeof i === "number" && Number.isInteger(i), "integer", ); } @@ -758,7 +788,7 @@ export function getCustomInteger( * @since unreleased */ export function getCustomStrings(vc: DatasetWithId, field: URL): string[] { - return deserializeFields(vc, field, (str: Literal) => str.value, "string"); + return deserializeFields(vc, field, (s) => typeof s === "string", "string"); } /** @@ -790,85 +820,11 @@ export function getCustomString( return deserializeField( accessCredential, field, - (str: Literal) => - str.datatype.equals(xmlSchemaTypes.string) ? str.value : undefined, + (s) => typeof s === "string", "string", ); } -const WELL_KNOWN_KEYS = [ - "forPersonalData", - "forPurpose", - "isProvidedTo", - "isProvidedToController", - "isProvidedToPerson", - "mode", - "inherit", - "hasStatus", - "isConsentForDataSubject", - gc.forPersonalData.value, - gc.forPurpose.value, - gc.isProvidedTo.value, - gc.isProvidedToController.value, - gc.isProvidedToPerson.value, - gc.hasStatus.value, - gc.isConsentForDataSubject.value, - acl.mode.value, - INHERIT.value, -] as const; - -/** - * Reads all the custom fields in the consent section of the provided Access Credential. - * - * @example - * ``` - * const accessRequest = await issueAccessRequest({...}, { - * ..., - * customFields: new Set([ - * { - * key: new URL("https://example.org/ns/customString"), - * value: "custom value", - * }, - * { - * key: new URL("https://example.org/ns/customInteger"), - * value: 1, - * }, - * ]), - * }); - * const customFields = getCustomFields(accessRequest); - * // s is "custom value" - * const s = customFields["https://example.org/ns/customString"]; - * // i is 1 - * const i = customFields["https://example.org/ns/custominteger"]; - * ``` - * - * @param accessCredential The Access Credential (Access Grant or Access Request) - * @returns an object keyed by the custom fields names, associated to their values. - * @since unreleased - */ -export function getCustomFields( - accessCredential: DatasetWithId, -): Record { - const credentialObject = JSON.parse(JSON.stringify(accessCredential)); - let consent; - if (isAccessGrant(credentialObject)) { - consent = (credentialObject as AccessGrant).credentialSubject - .providedConsent; - } else if (isAccessRequest(credentialObject)) { - consent = (credentialObject as AccessRequest).credentialSubject.hasConsent; - } - return ( - Object.entries(consent ?? {}) - // Filter out all well-known keys - .filter((entry) => !WELL_KNOWN_KEYS.includes(entry[0])) - .reduce( - (customFields, curEntry) => - Object.assign(customFields, { [`${curEntry[0]}`]: curEntry[1] }), - {}, - ) - ); -} - /** * This class wraps all the accessor functions on a raw Access Grant JSON object. * It wraps all the supported Access Grants data models, namely GConsent. diff --git a/src/gConsent/util/access.mock.ts b/src/gConsent/util/access.mock.ts index 125c0a37..21eb8d20 100644 --- a/src/gConsent/util/access.mock.ts +++ b/src/gConsent/util/access.mock.ts @@ -38,6 +38,8 @@ import type { AccessGrant } from "../type/AccessGrant"; import type { AccessRequest } from "../type/AccessRequest"; type RequestVcOptions = Partial<{ + subjectId: string; + inbox: string; issuer: string; resources: UrlString[]; modes: ResourceAccessMode[]; @@ -117,15 +119,7 @@ export const mockAccessRequestVc = async ( )) as unknown as AccessRequest; }; -export const mockAccessGrantObject = ( - options?: Partial<{ - issuer: string; - subjectId: string; - inherit: boolean; - resources: string[]; - inbox: string; - }>, -) => ({ +export const mockAccessGrantObject = (options?: RequestVcOptions) => ({ "@context": MOCK_CONTEXT, id: "https://some.credential", credentialSubject: { @@ -136,6 +130,7 @@ export const mockAccessGrantObject = ( mode: ["http://www.w3.org/ns/auth/acl#Read"], isProvidedTo: "https://some.requestor", inherit: options?.inherit ?? true, + ...toJson(new Set(options?.custom)), }, inbox: options?.inbox, }, @@ -153,14 +148,7 @@ export const mockAccessGrantObject = ( }); export const mockAccessGrantVc = async ( - options?: Partial<{ - issuer: string; - subjectId: string; - inherit: boolean; - resources: string[]; - purposes: string[]; - inbox: string; - }>, + options?: RequestVcOptions, // eslint-disable-next-line @typescript-eslint/no-explicit-any modify?: (asObject: Record) => void, ): Promise => {