diff --git a/docs/developer-guide/ajv-extensions.md b/docs/developer-guide/ajv-extensions.md index 10ab2c6da..dc728d06b 100644 --- a/docs/developer-guide/ajv-extensions.md +++ b/docs/developer-guide/ajv-extensions.md @@ -39,7 +39,7 @@ export const dynamicDefaults = new Map([ ### Asynchronous functions -If you need to execute asynchronous code, you should declare your function `async` (only supported for dynamicDefaults). +If you need to execute asynchronous code, you should declare your function `async`. It should return a synchronous function as ajv will not resolve any `Promise` returned by custom code. Asynchronous functions have access to additional context via the `ctx` argument: diff --git a/src/published-data/validator.service.spec.ts b/src/published-data/validator.service.spec.ts index 7da657dbe..6c8115826 100644 --- a/src/published-data/validator.service.spec.ts +++ b/src/published-data/validator.service.spec.ts @@ -6,6 +6,7 @@ import { ProposalsService } from "src/proposals/proposals.service"; import { ReadOnlyDatasetsService, ValidatorService } from "./validator.service"; import { ErrorObject } from "ajv"; +/* eslint-disable @typescript-eslint/no-explicit-any */ describe("ValidatorService", () => { let service: ValidatorService; @@ -125,7 +126,6 @@ describe("ValidatorService", () => { service = await createService({ metadataSchema: schema }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).dynamicDefaults.set( "userDefinedFunction", () => () => 5, @@ -152,7 +152,6 @@ describe("ValidatorService", () => { service = await createService({ metadataSchema: schema }); mockDataService.count.mockImplementation(() => 6); - // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).dynamicDefaults.set( "userDefinedAsyncFunction", async function (ctx: { datasetsService: ReadOnlyDatasetsService }) { @@ -187,4 +186,87 @@ describe("ValidatorService", () => { ); }); }); + + describe("Custom Keywords", () => { + it("should handle user-defined synchronous keyword", async () => { + const schema = { + type: "object", + properties: { + evenNumber: { + type: "number", + isEven: true, + }, + }, + }; + + service = await createService({ metadataSchema: schema }); + + (service as any).keywords = [ + { + keyword: "isEven", + validate: (schemaVal: boolean, data: number) => { + if (!schemaVal) return true; + return data % 2 === 0; + }, + }, + ]; + + const validData = { metadata: { evenNumber: 4 } }; + let errors = await service.validate(validData); + expect(errors).toBeNull(); + + const invalidData = { metadata: { evenNumber: 7 } }; + errors = await service.validate(invalidData); + expect(errors).toBeDefined(); + expect(errors![0].keyword).toBe("isEven"); + }); + + it("should handle user-defined asynchronous keyword", async () => { + const schema = { + type: "object", + properties: { + proposalId: { + type: "string", + proposalExists: true, + }, + }, + }; + + service = await createService({ metadataSchema: schema }); + + mockDataService.findOne.mockImplementation(async (id) => { + return id === "prop-123"; + }); + + const checkProposalExistence = async function (ctx: any) { + const proposalExists = await ctx.proposalService.findOne( + ctx.publishedData.metadata.proposalId, + ); + + return function () { + return proposalExists; + }; + }; + + (service as any).keywords = [ + { + keyword: "proposalExists", + validate: checkProposalExistence, + }, + ]; + + const validData = { metadata: { proposalId: "prop-123" } }; + let errors = await service.validate(validData); + expect(errors).toBeNull(); + expect(mockDataService.findOne).toHaveBeenCalledWith("prop-123"); + + const invalidData = { metadata: { proposalId: "prop-999" } }; + errors = await service.validate(invalidData); + expect(mockDataService.findOne).toHaveBeenCalledWith("prop-999"); + expect(errors).toBeDefined(); + expect(errors![0].message).toBe( + 'must pass "proposalExists" keyword validation', + ); + }); + }); }); diff --git a/src/published-data/validator.service.ts b/src/published-data/validator.service.ts index 80550fc93..4571fa0a6 100644 --- a/src/published-data/validator.service.ts +++ b/src/published-data/validator.service.ts @@ -5,7 +5,7 @@ import addKeywords from "ajv-keywords"; import def, { DynamicDefaultFunc, } from "ajv-keywords/dist/definitions/dynamicDefaults"; -import Ajv2019, { Schema } from "ajv/dist/2019"; +import Ajv2019, { KeywordDefinition, Schema } from "ajv/dist/2019"; import { isArray, isEmpty, isMap, isNil } from "lodash"; import { AttachmentsService } from "src/attachments/attachments.service"; import { DatasetsService } from "src/datasets/datasets.service"; @@ -30,10 +30,23 @@ export type ReadOnlyAttachmentsService = Pick< "findOne" | "findAll" | "count" >; +export type ValidationContext = { + publishedData: + | CreatePublishedDataV4Dto + | UpdatePublishedDataV4Dto + | PartialUpdatePublishedDataV4Dto; + proposalService: ReadOnlyProposalsService; + datasetsService: ReadOnlyDatasetsService; + attachmentsService: ReadOnlyAttachmentsService; +}; + +type Keyword = { keyword: string; validate: unknown }; + @Injectable() export class ValidatorService { private ajv: Ajv2019; private config: PublishedDataConfigDto; + private keywords: Keyword[] = []; private dynamicDefaults: Map = new Map([ ["currentYear", () => () => new Date().getFullYear()], ]); @@ -44,14 +57,6 @@ export class ValidatorService { private readonly datasetsService: DatasetsService, private readonly attachmentsService: AttachmentsService, ) { - this.ajv = new Ajv2019({ - useDefaults: "empty", - allErrors: true, - strict: false, - }); - addFormats(this.ajv); - addKeywords(this.ajv); - this.config = this.configService.get( "publishedDataConfig", { metadataSchema: {}, uiSchema: {} }, @@ -70,10 +75,7 @@ export class ValidatorService { const externalModule = this.loadExternalModule(modulePath!); if (isArray(externalModule.keywords)) { - for (const definition of externalModule.keywords) { - Logger.log(`Adding ajv keyword: '${definition.keyword}'`); - this.ajv.addKeyword(definition); - } + this.keywords = externalModule.keywords; } if (isMap(externalModule.dynamicDefaults)) { @@ -98,7 +100,21 @@ export class ValidatorService { return null; } - await this.loadDynamicDefaultFunctions(publishedData); + this.ajv = new Ajv2019({ + useDefaults: "empty", + allErrors: true, + strict: false, + }); + addFormats(this.ajv); + addKeywords(this.ajv); + const context = { + publishedData, + proposalService: this.proposalsService as ReadOnlyProposalsService, + datasetsService: this.datasetsService as ReadOnlyDatasetsService, + attachmentsService: this.attachmentsService as ReadOnlyAttachmentsService, + }; + await this.loadDynamicDefaults(context); + await this.loadKeywords(context); const validateFn = this.ajv.compile(this.config.metadataSchema as Schema); validateFn(publishedData.metadata); @@ -109,50 +125,84 @@ export class ValidatorService { Logger.debug(`Loading custom ajv code at ${path}`); // eslint-disable-next-line @typescript-eslint/no-require-imports const externalModule = require(path); - return externalModule; } - private async loadDynamicDefaultFunctions( - publishedData: - | CreatePublishedDataV4Dto - | UpdatePublishedDataV4Dto - | PartialUpdatePublishedDataV4Dto, - ) { + private async loadDynamicDefaults(context: ValidationContext) { for (const [name, implementation] of this.dynamicDefaults.entries()) { - if (typeof implementation !== "function") { - Logger.error( - `Ignoring dynamic defaults function ${name} should be of type 'function' not '${typeof implementation}'.`, + const resolved = await this.resolveDynamicDefault( + name, + implementation, + context, + ); + if (!resolved) continue; + def.DEFAULTS[name] = resolved; + } + } + + private async loadKeywords(context: ValidationContext) { + for (const keywordDefinition of this.keywords) { + const resolved = await this.resolveKeyword(keywordDefinition, context); + if (!resolved) continue; + this.ajv.addKeyword(resolved as KeywordDefinition); + } + } + + private async resolveDynamicDefault( + name: string, + implementation: unknown, + context: unknown, + ): Promise { + if (typeof implementation !== "function") { + Logger.error( + `Ignoring dynamicDefaults function '${name}' should be of type 'function' not '${typeof implementation}'.`, + ); + return null; + } + + if (implementation.constructor.name === "AsyncFunction") { + try { + const syncFunc = await implementation(context); + return () => syncFunc; + } catch (err) { + throw new Error( + `Executing dynamicDefaults function '${name}' failed with the following error:`, + { cause: err }, ); - continue; } - switch (implementation.constructor.name) { - case "Function": - def.DEFAULTS[name] = implementation; - break; - case "AsyncFunction": - /** - * Ajv cannot 'await' during validation. To get around this, we run the - * AsyncFunction now to perform any setup (like DB queries). - */ - try { - const syncFunc = await implementation({ - publishedData: publishedData, - proposalService: this - .proposalsService as ReadOnlyProposalsService, - datasetsService: this.datasetsService as ReadOnlyDatasetsService, - attachmentsService: this - .attachmentsService as ReadOnlyAttachmentsService, - }); - def.DEFAULTS[name] = () => syncFunc; - } catch (err) { - throw new Error( - `Executing dynamicDefaults function '${name}' failed with the following error:`, - { cause: err }, - ); - } - break; + } + return implementation as DynamicDefaultFunc; + } + + private async resolveKeyword( + keywordDefinition: Keyword, + context: unknown, + ): Promise { + const { keyword, validate } = keywordDefinition; + + if (typeof validate !== "function") { + Logger.error( + `Ignoring keyword '${keyword}' validate should be of type 'function' not '${typeof validate}'.`, + ); + return null; + } + + if (validate.constructor.name === "AsyncFunction") { + try { + const resolvedValidate = await validate(context); + const normalized: Keyword = { + ...keywordDefinition, + validate: resolvedValidate, + }; + return normalized; + } catch (err) { + throw new Error( + `Executing keyword '${keyword}' failed with the following error:`, + { cause: err }, + ); } } + + return keywordDefinition; } }