Skip to content

Commit

Permalink
feat(core-api): add createIsJwsGeneralTypeGuard, createAjvTypeGuard<T>
Browse files Browse the repository at this point in the history
1. createAjvTypeGuard<T>() 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<JwsGeneral>
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<T>() 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<JWSGeneral> {
    // 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:
hyperledger#3471 (comment)

Signed-off-by: Peter Somogyvari <[email protected]>
  • Loading branch information
petermetz committed Sep 2, 2024
1 parent 7e7bb44 commit 957da7c
Show file tree
Hide file tree
Showing 8 changed files with 238 additions and 18 deletions.
3 changes: 3 additions & 0 deletions packages/cactus-core-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T>} 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<JWSGeneral>(
* OpenApiJson.components.schemas.JWSGeneral,
* );
* return createAjvTypeGuard<JWSGeneral>(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<JWSGeneral>(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<T>(
validator: ValidateFunction<T>,
): (x: unknown) => x is T {
return (x: unknown): x is T => {
return validator(x);
};
}
Original file line number Diff line number Diff line change
@@ -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<JWSGeneral> {
* // 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<JWSGeneral>({
$ref: "core-api#/components/schemas/JWSGeneral",
});

return createAjvTypeGuard<JWSGeneral>(validator);
}
3 changes: 3 additions & 0 deletions packages/cactus-core-api/src/main/typescript/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
@@ -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<JWSGeneral>({
$ref: "core-api#/components/schemas/JWSGeneral",
});

const isJwsGeneral = createAjvTypeGuard<JWSGeneral>(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");
});
});
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ICactusPluginOptions,
JWSGeneral,
JWSRecipient,
createIsJwsGeneralTypeGuard,
} from "@hyperledger/cactus-core-api";

import { PluginRegistry, ConsortiumRepository } from "@hyperledger/cactus-core";
Expand Down Expand Up @@ -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 {
Expand All @@ -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 ||
Expand Down Expand Up @@ -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<JWSGeneral> {
Expand Down
43 changes: 30 additions & 13 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 957da7c

Please sign in to comment.