From 957da7c3e1d80068391485a825ba6bb1e68333ac Mon Sep 17 00:00:00 2001 From: Peter Somogyvari Date: Mon, 26 Aug 2024 19:19:18 -0700 Subject: [PATCH] feat(core-api): add createIsJwsGeneralTypeGuard, createAjvTypeGuard 1. createAjvTypeGuard() is the lower level utility which can be used to construct the more convenient, higher level type predicates/type guards such as createIsJwsGeneralTypeGuard() which uses createAjvTypeGuard under the hood. 2. This commit is also meant to be establishing a larger, more generic pattern of us being able to create type guards out of the Open API specs in a convenient way instead of having to write the validation code by hand. An example usage of the new createAjvTypeGuard() utility is the createIsJwsGeneralTypeGuard() function itself. An example usage of the new createIsJwsGeneralTypeGuard() can be found in packages/cactus-plugin-consortium-manual/src/main/typescript/plugin-consortium-manual.ts The code documentation contains examples as well for maximum discoverabilty and I'll also include it here: ```typescript import { JWSGeneral } from "@hyperledger/cactus-core-api"; import { createIsJwsGeneralTypeGuard } from "@hyperledger/cactus-core-api"; export class PluginConsortiumManual { private readonly isJwsGeneral: (x: unknown) => x is JWSGeneral; constructor() { // Creating the type-guard function is relatively costly due to the Ajv schema // compilation that needs to happen as part of it so it is good practice to // cache the type-guard function as much as possible, for examle by adding it // as a class member on a long-lived object such as a plugin instance which is // expected to match the life-cycle of the API server NodeJS process itself. // The specific anti-pattern would be to create a new type-guard function // for each request received by a plugin as this would affect performance // negatively. this.isJwsGeneral = createIsJwsGeneralTypeGuard(); } public async getNodeJws(): Promise { // rest of the implementation that produces a JWS ... const jws = await joseGeneralSign.sign(); if (!this.isJwsGeneral(jws)) { throw new TypeError("Jose GeneralSign.sign() gave non-JWSGeneral type"); } return jws; } } ``` Relevant discussion took place here: https://github.com/hyperledger/cacti/pull/3471#discussion_r1731894747 Signed-off-by: Peter Somogyvari --- packages/cactus-core-api/package.json | 3 + .../open-api/create-ajv-type-guard.ts | 66 +++++++++++++++++++ .../create-is-jws-general-type-guard.ts | 61 +++++++++++++++++ .../src/main/typescript/public-api.ts | 3 + .../open-api/create-ajv-type-guard.test.ts | 36 ++++++++++ .../create-is-jws-general-type-guard.test.ts | 25 +++++++ .../typescript/plugin-consortium-manual.ts | 19 ++++-- yarn.lock | 43 ++++++++---- 8 files changed, 238 insertions(+), 18 deletions(-) create mode 100644 packages/cactus-core-api/src/main/typescript/open-api/create-ajv-type-guard.ts create mode 100644 packages/cactus-core-api/src/main/typescript/open-api/create-is-jws-general-type-guard.ts create mode 100644 packages/cactus-core-api/src/test/typescript/unit/open-api/create-ajv-type-guard.test.ts create mode 100644 packages/cactus-core-api/src/test/typescript/unit/open-api/create-is-jws-general-type-guard.test.ts diff --git a/packages/cactus-core-api/package.json b/packages/cactus-core-api/package.json index bcf26eece0..9ef936270b 100644 --- a/packages/cactus-core-api/package.json +++ b/packages/cactus-core-api/package.json @@ -62,6 +62,9 @@ "dependencies": { "@grpc/grpc-js": "1.11.1", "@hyperledger/cactus-common": "2.0.0-rc.3", + "ajv": "8.17.1", + "ajv-draft-04": "1.0.0", + "ajv-formats": "3.0.1", "axios": "1.7.5", "google-protobuf": "3.21.4" }, diff --git a/packages/cactus-core-api/src/main/typescript/open-api/create-ajv-type-guard.ts b/packages/cactus-core-api/src/main/typescript/open-api/create-ajv-type-guard.ts new file mode 100644 index 0000000000..e1b292d6aa --- /dev/null +++ b/packages/cactus-core-api/src/main/typescript/open-api/create-ajv-type-guard.ts @@ -0,0 +1,66 @@ +import type { ValidateFunction } from "ajv"; + +/** + * Creates a TypeScript type guard based on an `ajv` validator. + * + * @template T The type of the data that the validator expects. This can be + * one of the data model types that we generate from the OpenAPI specifications. + * It could also be a schema that you defined in your code directly, but that is + * not recommended since if you are going to define a schema then it's best to + * do so within the Open API specification file(s) (`openapi.tpl.json` files). + * + * @param {ValidateFunction} validator An `ajv` validator that validates data against a specific JSON schema. + * You must make sure that this parameter was indeed constructed to validate the + * specific `T` type that you are intending it for. See the example below for + * further details on this. + * @returns {(x: unknown) => x is T} A user-defined TypeScript type guard that + * checks if an unknown value matches the schema defined in the validator and + * also performs the ever-useful type-narrowing which helps writing less buggy + * code and enhance the compiler's ability to catch issues during development. + * + * @example + * + * ### Define a validator for the `JWSGeneral` type from the openapi.json + * + * ```typescript + * import Ajv from "ajv"; + * + * import * as OpenApiJson from "../../json/openapi.json"; + * import { JWSGeneral } from "../generated/openapi/typescript-axios/api"; + * import { createAjvTypeGuard } from "./create-ajv-type-guard"; + * + * export function createIsJwsGeneral(): (x: unknown) => x is JWSGeneral { + * const ajv = new Ajv(); + * const validator = ajv.compile( + * OpenApiJson.components.schemas.JWSGeneral, + * ); + * return createAjvTypeGuard(validator); + * } + * ``` + * + * ### Then use it elsewhere in the code for validation & type-narrowing + * + * ```typescript + * // make sure to cache the validator you created here because it's costly to + * // re-create it (in terms of hardware resources such as CPU time) + * const isJWSGeneral = createAjvTypeGuard(validateJWSGeneral); + * + * const data: unknown = { payload: "some-payload" }; + * + * if (!isJWSGeneral(data)) { + * throw new TypeError('Data is not a JWSGeneral object'); + * } + * // Now you can safely access properties of data as a JWSGeneral object + * // **without** having to perform unsafe type casting such as `as JWSGeneral` + * console.log(data.payload); + * console.log(data.signatures); + * ``` + * + */ +export function createAjvTypeGuard( + validator: ValidateFunction, +): (x: unknown) => x is T { + return (x: unknown): x is T => { + return validator(x); + }; +} diff --git a/packages/cactus-core-api/src/main/typescript/open-api/create-is-jws-general-type-guard.ts b/packages/cactus-core-api/src/main/typescript/open-api/create-is-jws-general-type-guard.ts new file mode 100644 index 0000000000..4004481341 --- /dev/null +++ b/packages/cactus-core-api/src/main/typescript/open-api/create-is-jws-general-type-guard.ts @@ -0,0 +1,61 @@ +import Ajv from "ajv-draft-04"; +import addFormats from "ajv-formats"; + +import * as OpenApiJson from "../../json/openapi.json"; +import { JWSGeneral } from "../generated/openapi/typescript-axios/api"; +import { createAjvTypeGuard } from "./create-ajv-type-guard"; + +/** + * + * @example + * + * ```typescript + * import { JWSGeneral } from "@hyperledger/cactus-core-api"; + * import { createIsJwsGeneralTypeGuard } from "@hyperledger/cactus-core-api"; + * + * export class PluginConsortiumManual { + * private readonly isJwsGeneral: (x: unknown) => x is JWSGeneral; + * + * constructor() { + * // Creating the type-guard function is relatively costly due to the Ajv schema + * // compilation that needs to happen as part of it so it is good practice to + * // cache the type-guard function as much as possible, for example by adding it + * // as a class member on a long-lived object such as a plugin instance which is + * // expected to match the life-cycle of the API server NodeJS process itself. + * // The specific anti-pattern would be to create a new type-guard function + * // for each request received by a plugin as this would affect performance + * // negatively. + * this.isJwsGeneral = createIsJwsGeneralTypeGuard(); + * } + * + * public async getNodeJws(): Promise { + * // rest of the implementation that produces a JWS ... + * const jws = await joseGeneralSign.sign(); + * + * if (!this.isJwsGeneral(jws)) { + * throw new TypeError("Jose GeneralSign.sign() gave non-JWSGeneral type"); + * } + * return jws; + * } + * } + * + * ``` + * + * @returns A user-defined Typescript type-guard (which is just another function) + * that is primed to do type-narrowing and runtime type-checking as well. + * + * @see {createAjvTypeGuard()} + * @see https://www.typescriptlang.org/docs/handbook/2/narrowing.html + * @see https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates + */ +export function createIsJwsGeneralTypeGuard(): (x: unknown) => x is JWSGeneral { + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + ajv.addSchema(OpenApiJson, "core-api"); + + const validator = ajv.compile({ + $ref: "core-api#/components/schemas/JWSGeneral", + }); + + return createAjvTypeGuard(validator); +} diff --git a/packages/cactus-core-api/src/main/typescript/public-api.ts b/packages/cactus-core-api/src/main/typescript/public-api.ts index 7221bf1a6c..5e4a258e8a 100755 --- a/packages/cactus-core-api/src/main/typescript/public-api.ts +++ b/packages/cactus-core-api/src/main/typescript/public-api.ts @@ -52,3 +52,6 @@ export { isIPluginGrpcService } from "./plugin/grpc-service/i-plugin-grpc-servic export { ICrpcSvcRegistration } from "./plugin/crpc-service/i-plugin-crpc-service"; export { IPluginCrpcService } from "./plugin/crpc-service/i-plugin-crpc-service"; export { isIPluginCrpcService } from "./plugin/crpc-service/i-plugin-crpc-service"; + +export { createAjvTypeGuard } from "./open-api/create-ajv-type-guard"; +export { createIsJwsGeneralTypeGuard } from "./open-api/create-is-jws-general-type-guard"; diff --git a/packages/cactus-core-api/src/test/typescript/unit/open-api/create-ajv-type-guard.test.ts b/packages/cactus-core-api/src/test/typescript/unit/open-api/create-ajv-type-guard.test.ts new file mode 100644 index 0000000000..22bd23a8a0 --- /dev/null +++ b/packages/cactus-core-api/src/test/typescript/unit/open-api/create-ajv-type-guard.test.ts @@ -0,0 +1,36 @@ +import "jest-extended"; +import Ajv from "ajv-draft-04"; +import addFormats from "ajv-formats"; + +import * as OpenApiJson from "../../../../main/json/openapi.json"; +import { JWSGeneral } from "../../../../main/typescript/generated/openapi/typescript-axios/api"; +import { createAjvTypeGuard } from "../../../../main/typescript/open-api/create-ajv-type-guard"; + +describe("createAjvTypeGuard()", () => { + it("creates Generic type-guards that work", () => { + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + ajv.addSchema(OpenApiJson, "core-api"); + + const validator = ajv.compile({ + $ref: "core-api#/components/schemas/JWSGeneral", + }); + + const isJwsGeneral = createAjvTypeGuard(validator); + + const jwsGeneralGood1: JWSGeneral = { payload: "stuff", signatures: [] }; + const jwsGeneralBad1 = { payload: "stuff", signatures: {} } as JWSGeneral; + const jwsGeneralBad2 = { payload: "", signatures: {} } as JWSGeneral; + + expect(isJwsGeneral(jwsGeneralGood1)).toBeTrue(); + expect(isJwsGeneral(jwsGeneralBad1)).toBeFalse(); + expect(isJwsGeneral(jwsGeneralBad2)).toBeFalse(); + + // verify type-narrowing to be working + const jwsGeneralGood2: unknown = { payload: "stuff", signatures: [] }; + if (!isJwsGeneral(jwsGeneralGood2)) { + throw new Error("isJwsGeneral test misclassified valid JWSGeneral."); + } + expect(jwsGeneralGood2.payload).toEqual("stuff"); + }); +}); diff --git a/packages/cactus-core-api/src/test/typescript/unit/open-api/create-is-jws-general-type-guard.test.ts b/packages/cactus-core-api/src/test/typescript/unit/open-api/create-is-jws-general-type-guard.test.ts new file mode 100644 index 0000000000..5c7730c5cb --- /dev/null +++ b/packages/cactus-core-api/src/test/typescript/unit/open-api/create-is-jws-general-type-guard.test.ts @@ -0,0 +1,25 @@ +import "jest-extended"; + +import { JWSGeneral } from "../../../../main/typescript/generated/openapi/typescript-axios/api"; +import { createIsJwsGeneralTypeGuard } from "../../../../main/typescript/open-api/create-is-jws-general-type-guard"; + +describe("createIsJwsGeneralTypeGuard()", () => { + it("creates JWSGeneral type-guards that work", () => { + const isJwsGeneral = createIsJwsGeneralTypeGuard(); + + const jwsGeneralGood1: JWSGeneral = { payload: "stuff", signatures: [] }; + const jwsGeneralBad1 = { payload: "stuff", signatures: {} } as JWSGeneral; + const jwsGeneralBad2 = { payload: "", signatures: {} } as JWSGeneral; + + expect(isJwsGeneral(jwsGeneralGood1)).toBeTrue(); + expect(isJwsGeneral(jwsGeneralBad1)).toBeFalse(); + expect(isJwsGeneral(jwsGeneralBad2)).toBeFalse(); + + // verify type-narrowing to be working + const jwsGeneralGood2: unknown = { payload: "stuff", signatures: [] }; + if (!isJwsGeneral(jwsGeneralGood2)) { + throw new Error("isJwsGeneral test misclassified valid JWSGeneral."); + } + expect(jwsGeneralGood2.payload).toEqual("stuff"); + }); +}); diff --git a/packages/cactus-plugin-consortium-manual/src/main/typescript/plugin-consortium-manual.ts b/packages/cactus-plugin-consortium-manual/src/main/typescript/plugin-consortium-manual.ts index 94ebc6fe32..403929b1e1 100644 --- a/packages/cactus-plugin-consortium-manual/src/main/typescript/plugin-consortium-manual.ts +++ b/packages/cactus-plugin-consortium-manual/src/main/typescript/plugin-consortium-manual.ts @@ -13,6 +13,7 @@ import { ICactusPluginOptions, JWSGeneral, JWSRecipient, + createIsJwsGeneralTypeGuard, } from "@hyperledger/cactus-core-api"; import { PluginRegistry, ConsortiumRepository } from "@hyperledger/cactus-core"; @@ -59,6 +60,7 @@ export class PluginConsortiumManual private readonly log: Logger; private readonly instanceId: string; private readonly repo: ConsortiumRepository; + private readonly isJwsGeneral: (x: unknown) => x is JWSGeneral; private endpoints: IWebServiceEndpoint[] | undefined; public get className(): string { @@ -82,6 +84,7 @@ export class PluginConsortiumManual this.instanceId = this.options.instanceId; this.repo = new ConsortiumRepository({ db: options.consortiumDatabase }); + this.isJwsGeneral = createIsJwsGeneralTypeGuard(); this.prometheusExporter = options.prometheusExporter || @@ -204,16 +207,22 @@ export class PluginConsortiumManual const _protected = { iat: Date.now(), jti: uuidv4(), - iss: "Hyperledger Cactus", + iss: "Cacti", }; - // TODO: double check if this casting is safe (it is supposed to be) + const encoder = new TextEncoder(); - const sign = new GeneralSign(encoder.encode(payloadJson)); + const encodedPayload = encoder.encode(payloadJson); + const sign = new GeneralSign(encodedPayload); sign .addSignature(keyPair) .setProtectedHeader({ alg: "ES256K", _protected }); - const jwsGeneral = await sign.sign(); - return jwsGeneral as JWSGeneral; + + const jws = await sign.sign(); + + if (!this.isJwsGeneral(jws)) { + throw new TypeError("Jose GeneralSign.sign() gave non-JWSGeneral type"); + } + return jws; } public async getConsortiumJws(): Promise { diff --git a/yarn.lock b/yarn.lock index 3bd5600caf..13638456ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9546,6 +9546,9 @@ __metadata: "@hyperledger/cactus-common": "npm:2.0.0-rc.3" "@types/express": "npm:4.17.21" "@types/google-protobuf": "npm:3.15.5" + ajv: "npm:8.17.1" + ajv-draft-04: "npm:1.0.0" + ajv-formats: "npm:3.0.1" axios: "npm:1.7.5" google-protobuf: "npm:3.21.4" grpc-tools: "npm:1.12.4" @@ -19222,7 +19225,7 @@ __metadata: languageName: node linkType: hard -"ajv-draft-04@npm:^1.0.0": +"ajv-draft-04@npm:1.0.0, ajv-draft-04@npm:^1.0.0": version: 1.0.0 resolution: "ajv-draft-04@npm:1.0.0" peerDependencies: @@ -19248,6 +19251,20 @@ __metadata: languageName: node linkType: hard +"ajv-formats@npm:3.0.1": + version: 3.0.1 + resolution: "ajv-formats@npm:3.0.1" + dependencies: + ajv: "npm:^8.0.0" + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + checksum: 10/5679b9f9ced9d0213a202a37f3aa91efcffe59a6de1a6e3da5c873344d3c161820a1f11cc29899661fee36271fd2895dd3851b6461c902a752ad661d1c1e8722 + languageName: node + linkType: hard + "ajv-keywords@npm:^1.0.0": version: 1.5.1 resolution: "ajv-keywords@npm:1.5.1" @@ -19298,6 +19315,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:8.17.1, ajv@npm:^8.14.0": + version: 8.17.1 + resolution: "ajv@npm:8.17.1" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10/ee3c62162c953e91986c838f004132b6a253d700f1e51253b99791e2dbfdb39161bc950ebdc2f156f8568035bb5ed8be7bd78289cd9ecbf3381fe8f5b82e3f33 + languageName: node + linkType: hard + "ajv@npm:^4.7.0": version: 4.11.8 resolution: "ajv@npm:4.11.8" @@ -19344,18 +19373,6 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.14.0": - version: 8.17.1 - resolution: "ajv@npm:8.17.1" - dependencies: - fast-deep-equal: "npm:^3.1.3" - fast-uri: "npm:^3.0.1" - json-schema-traverse: "npm:^1.0.0" - require-from-string: "npm:^2.0.2" - checksum: 10/ee3c62162c953e91986c838f004132b6a253d700f1e51253b99791e2dbfdb39161bc950ebdc2f156f8568035bb5ed8be7bd78289cd9ecbf3381fe8f5b82e3f33 - languageName: node - linkType: hard - "ajv@npm:^8.8.0": version: 8.11.0 resolution: "ajv@npm:8.11.0"