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"