From 464932695f5b0dbb7337b1d4ffcc268d92c7eac5 Mon Sep 17 00:00:00 2001 From: Spencer Bliven Date: Fri, 6 Dec 2024 23:11:22 +0100 Subject: [PATCH] Complete refactor of job configuration as NestJS modules - Developing new actions is documented in README.md for developers - Move everything related to job config to src/config/job-config - Major refactor of parsing from jobConfig.yaml to JobConfig objects - Create module, interface, and factory for each JobAction - Add handlebar-utils to help consistently applying templates to jobs and Dtos. - In test contexts the handlebars helpers may not be registered, so avoid using custom helpers or register them in the test - Add options to LogJobAction. This is also good for templating tests - Change MailService.sendMail to match nest's MailerService.sendMail - Accept empty jobConfigurationFile as no jobs - CaslModule now depends indirectly on the MailerModule. Thus all controllers need to either mock CaslAbilityFactory or add a mock MailerModule before importing CaslModule. - Most tests disable jobConfig except for those that directly test it. These use test/config/jobconfig.yaml --- src/app.module.ts | 4 + src/auth/auth.module.ts | 4 +- src/casl/casl-ability.factory.spec.ts | 19 +- src/casl/casl-ability.factory.ts | 15 +- src/casl/casl.module.ts | 3 +- src/common/handlebars-helpers.ts | 10 + src/common/mail.service.ts | 31 +- src/config/configuration.ts | 34 +-- src/config/job-config/README.md | 33 +++ .../actions/defaultjobactions.module.ts | 90 ++++++ .../emailaction/emailaction.factory.ts | 21 ++ .../emailaction/emailaction.interface.ts | 31 ++ .../actions/emailaction/emailaction.module.ts | 10 + .../actions/emailaction/emailaction.ts | 68 +++++ .../actions/logaction/logaction.factory.ts | 21 ++ .../actions/logaction/logaction.interface.ts | 28 ++ .../actions/logaction/logaction.module.ts | 8 + .../job-config/actions/logaction/logaction.ts | 47 +++ .../rabbitmqaction/rabbitmqaction.factory.ts | 20 ++ .../rabbitmqaction.interface.ts | 37 +++ .../rabbitmqaction/rabbitmqaction.module.ts | 10 + .../actions/rabbitmqaction}/rabbitmqaction.ts | 49 ++-- .../actions/urlaction/urlaction.factory.ts | 20 ++ .../actions/urlaction/urlaction.interface.ts | 45 +++ .../actions/urlaction/urlaction.module.ts | 8 + .../job-config/actions/urlaction/urlaction.ts | 83 ++++++ .../validateaction/validateaction.factory.ts | 20 ++ .../validateaction.interface.ts | 21 ++ .../validateaction/validateaction.module.ts | 8 + .../validateaction}/validateaction.spec.ts | 9 +- .../actions/validateaction}/validateaction.ts | 59 ++-- src/config/job-config/handlebar-utils.ts | 30 ++ src/config/job-config/jobconfig.interface.ts | 85 ++++++ src/config/job-config/jobconfig.module.ts | 11 + .../job-config/jobconfig.schema.ts} | 0 src/config/job-config/jobconfig.service.ts | 132 +++++++++ src/config/job-config/jobconfig.spec.ts | 55 ++++ src/datasets/datasets.controller.spec.ts | 9 +- src/datasets/datasets.module.ts | 5 +- src/elastic-search/elastic-search.module.ts | 11 +- .../instruments.controller.spec.ts | 3 +- src/instruments/instruments.module.ts | 5 +- src/jobs/actions/emailaction.ts | 128 --------- src/jobs/actions/logaction.ts | 31 -- src/jobs/actions/urlaction.ts | 128 --------- src/jobs/config/jobconfig.spec.ts | 26 -- src/jobs/config/jobconfig.ts | 272 ------------------ src/jobs/jobs.controller.spec.ts | 23 +- src/jobs/jobs.controller.ts | 34 ++- src/jobs/jobs.module.ts | 13 +- src/logbooks/logbooks.controller.spec.ts | 6 +- src/logbooks/logbooks.module.ts | 5 +- src/origdatablocks/origdatablocks.module.ts | 5 +- src/policies/policies.controller.spec.ts | 6 +- src/policies/policies.module.ts | 5 +- src/proposals/dto/update-proposal.dto.ts | 1 - src/proposals/proposals.controller.spec.ts | 7 +- src/proposals/proposals.module.ts | 5 +- .../published-data.controller.spec.ts | 9 +- src/published-data/published-data.module.ts | 5 +- src/samples/samples.controller.spec.ts | 7 +- src/samples/samples.module.ts | 5 +- src/users/user-identities.controller.spec.ts | 5 +- src/users/users.controller.spec.ts | 5 +- src/users/users.module.ts | 4 +- 65 files changed, 1157 insertions(+), 790 deletions(-) create mode 100644 src/config/job-config/README.md create mode 100644 src/config/job-config/actions/defaultjobactions.module.ts create mode 100644 src/config/job-config/actions/emailaction/emailaction.factory.ts create mode 100644 src/config/job-config/actions/emailaction/emailaction.interface.ts create mode 100644 src/config/job-config/actions/emailaction/emailaction.module.ts create mode 100644 src/config/job-config/actions/emailaction/emailaction.ts create mode 100644 src/config/job-config/actions/logaction/logaction.factory.ts create mode 100644 src/config/job-config/actions/logaction/logaction.interface.ts create mode 100644 src/config/job-config/actions/logaction/logaction.module.ts create mode 100644 src/config/job-config/actions/logaction/logaction.ts create mode 100644 src/config/job-config/actions/rabbitmqaction/rabbitmqaction.factory.ts create mode 100644 src/config/job-config/actions/rabbitmqaction/rabbitmqaction.interface.ts create mode 100644 src/config/job-config/actions/rabbitmqaction/rabbitmqaction.module.ts rename src/{jobs/actions => config/job-config/actions/rabbitmqaction}/rabbitmqaction.ts (63%) create mode 100644 src/config/job-config/actions/urlaction/urlaction.factory.ts create mode 100644 src/config/job-config/actions/urlaction/urlaction.interface.ts create mode 100644 src/config/job-config/actions/urlaction/urlaction.module.ts create mode 100644 src/config/job-config/actions/urlaction/urlaction.ts create mode 100644 src/config/job-config/actions/validateaction/validateaction.factory.ts create mode 100644 src/config/job-config/actions/validateaction/validateaction.interface.ts create mode 100644 src/config/job-config/actions/validateaction/validateaction.module.ts rename src/{jobs/actions => config/job-config/actions/validateaction}/validateaction.spec.ts (91%) rename src/{jobs/actions => config/job-config/actions/validateaction}/validateaction.ts (85%) create mode 100644 src/config/job-config/handlebar-utils.ts create mode 100644 src/config/job-config/jobconfig.interface.ts create mode 100644 src/config/job-config/jobconfig.module.ts rename src/{jobs/config/jobConfig.schema.ts => config/job-config/jobconfig.schema.ts} (100%) create mode 100644 src/config/job-config/jobconfig.service.ts create mode 100644 src/config/job-config/jobconfig.spec.ts delete mode 100644 src/jobs/actions/emailaction.ts delete mode 100644 src/jobs/actions/logaction.ts delete mode 100644 src/jobs/actions/urlaction.ts delete mode 100644 src/jobs/config/jobconfig.spec.ts delete mode 100644 src/jobs/config/jobconfig.ts diff --git a/src/app.module.ts b/src/app.module.ts index 32155b46b..d7f3349e3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -39,6 +39,8 @@ import { EventEmitterModule } from "@nestjs/event-emitter"; import { AdminModule } from "./admin/admin.module"; import { HealthModule } from "./health/health.module"; import { LoggerModule } from "./loggers/logger.module"; +import { JobConfigModule } from "./config/job-config/jobconfig.module"; +import { DefaultJobActionFactories } from "./config/job-config/actions/defaultjobactions.module"; @Module({ imports: [ @@ -49,6 +51,8 @@ import { LoggerModule } from "./loggers/logger.module"; ConfigModule.forRoot({ load: [configuration], }), + DefaultJobActionFactories, + JobConfigModule, LoggerModule, DatablocksModule, DatasetsModule, diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 7f65ff246..78d468c38 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -13,7 +13,7 @@ import { OidcConfig } from "src/config/configuration"; import { BuildOpenIdClient, OidcStrategy } from "./strategies/oidc.strategy"; import { accessGroupServiceFactory } from "./access-group-provider/access-group-service-factory"; import { AccessGroupService } from "./access-group-provider/access-group.service"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; +import { CaslModule } from "src/casl/casl.module"; const OidcStrategyFactory = { provide: "OidcStrategy", @@ -56,6 +56,7 @@ const OidcStrategyFactory = { property: "user", session: false, }), + CaslModule, UsersModule, ], providers: [ @@ -63,7 +64,6 @@ const OidcStrategyFactory = { JwtStrategy, LdapStrategy, LocalStrategy, - CaslAbilityFactory, OidcStrategyFactory, accessGroupServiceFactory, ], diff --git a/src/casl/casl-ability.factory.spec.ts b/src/casl/casl-ability.factory.spec.ts index 80df5bb09..b13f636a5 100644 --- a/src/casl/casl-ability.factory.spec.ts +++ b/src/casl/casl-ability.factory.spec.ts @@ -1,7 +1,24 @@ +import { MailerModule, MailerService } from "@nestjs-modules/mailer"; import { CaslAbilityFactory } from "./casl-ability.factory"; +import { Test, TestingModule } from "@nestjs/testing"; +import { CaslModule } from "./casl.module"; + +class MailerServiceMock {} describe("CaslAbilityFactory", () => { + let casl: CaslAbilityFactory; + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [MailerModule.forRoot(), CaslModule], + }) + .overrideProvider(MailerService) + .useClass(MailerServiceMock) + .compile(); + + casl = module.get(CaslAbilityFactory); + }); + it("should be defined", () => { - expect(new CaslAbilityFactory()).toBeDefined(); + expect(casl).toBeDefined(); }); }); diff --git a/src/casl/casl-ability.factory.ts b/src/casl/casl-ability.factory.ts index d3dd8a4f9..01662670f 100644 --- a/src/casl/casl-ability.factory.ts +++ b/src/casl/casl-ability.factory.ts @@ -26,12 +26,13 @@ import { UserSettings } from "src/users/schemas/user-settings.schema"; import { User } from "src/users/schemas/user.schema"; import { Action } from "./action.enum"; import configuration from "src/config/configuration"; +import { JobConfigService } from "src/config/job-config/jobconfig.service"; import { CreateJobAuth, StatusUpdateJobAuth, } from "src/jobs/types/jobs-auth.enum"; -import { JobConfig } from "src/jobs/config/jobconfig"; +import { JobConfig } from "src/config/job-config/jobconfig.interface"; type Subjects = | string @@ -60,6 +61,10 @@ export type AppAbility = MongoAbility; @Injectable() export class CaslAbilityFactory { + constructor(private jobConfigService: JobConfigService) { + //Logger.log(`Creating CaslAbilityFactory with ${jobConfigService ? Object.keys(jobConfigService.allJobConfigs).length : 0} job types`); + } + private endpointAccessors: { [endpoint: string]: (user: JWTUser) => AppAbility; } = { @@ -356,7 +361,7 @@ export class CaslAbilityFactory { // job creation if ( - configuration().jobConfiguration.some( + Object.values(this.jobConfigService.allJobConfigs).some( (j) => j.create.auth == CreateJobAuth.All, ) ) { @@ -366,7 +371,7 @@ export class CaslAbilityFactory { } cannot(Action.JobRead, JobClass); if ( - configuration().jobConfiguration.some( + Object.values(this.jobConfigService.allJobConfigs).some( (j) => j.statusUpdate.auth == StatusUpdateJobAuth.All, ) ) { @@ -425,7 +430,7 @@ export class CaslAbilityFactory { can(Action.JobRead, JobClass); if ( - configuration().jobConfiguration.some( + Object.values(this.jobConfigService.allJobConfigs).some( (j) => j.create.auth && jobCreateEndPointAuthorizationValues.includes( @@ -448,7 +453,7 @@ export class CaslAbilityFactory { can(Action.JobStatusUpdate, JobClass); } else { if ( - configuration().jobConfiguration.some( + Object.values(this.jobConfigService.allJobConfigs).some( (j) => j.statusUpdate.auth && jobUpdateEndPointAuthorizationValues.includes( diff --git a/src/casl/casl.module.ts b/src/casl/casl.module.ts index eb9986a86..d50f2ae4f 100644 --- a/src/casl/casl.module.ts +++ b/src/casl/casl.module.ts @@ -1,7 +1,8 @@ import { Module } from "@nestjs/common"; import { CaslAbilityFactory } from "./casl-ability.factory"; - +import { JobConfigModule } from "src/config/job-config/jobconfig.module"; @Module({ + imports: [JobConfigModule], providers: [CaslAbilityFactory], exports: [CaslAbilityFactory], }) diff --git a/src/common/handlebars-helpers.ts b/src/common/handlebars-helpers.ts index e9e7ffb02..2589b4c71 100644 --- a/src/common/handlebars-helpers.ts +++ b/src/common/handlebars-helpers.ts @@ -131,3 +131,13 @@ export const urlencode = (context: string): string => { export const base64enc = (context: string): string => { return btoa(context); }; + +export const handlebarsHelpers = { + unwrapJSON: unwrapJSON, + keyToWord: formatCamelCase, + eq: (a: unknown, b: unknown) => a === b, + jsonify: jsonify, + job_v3: job_v3, + urlencode: urlencode, + base64enc: base64enc, +}; diff --git a/src/common/mail.service.ts b/src/common/mail.service.ts index a8cbace98..51e779e10 100644 --- a/src/common/mail.service.ts +++ b/src/common/mail.service.ts @@ -1,28 +1,25 @@ -import { MailerService } from "@nestjs-modules/mailer"; +import { ISendMailOptions, MailerService } from "@nestjs-modules/mailer"; import { Injectable, Logger } from "@nestjs/common"; +import { SentMessageInfo } from "nodemailer"; +/** + * Service for sending emails using nodemailer. + * + * Use this rather than MailerService directly to allow configuration in AppModule + */ @Injectable() export class MailService { constructor(private readonly mailerService: MailerService) {} - async sendMail( - to: string, - cc: string, - subject: string, - mailText: string | null, - html: string | null = null, - ) { + async sendMail(options: ISendMailOptions): Promise { try { - Logger.log("Sending email to: " + to, "Utils.sendMail"); - await this.mailerService.sendMail({ - to, - ...(cc && { cc }), - ...(subject && { subject }), - ...(html && { html }), - ...(mailText && { mailText }), - }); + Logger.log("Sending email to: " + options.to, "Utils.sendMail"); + await this.mailerService.sendMail(options); } catch (error) { - Logger.error("Failed sending email to: " + to, "MailService.sendMail"); + Logger.error( + "Failed sending email to: " + options.to, + "MailService.sendMail", + ); Logger.error(error, "MailService.sendMail"); } } diff --git a/src/config/configuration.ts b/src/config/configuration.ts index b321ba8d5..d0dea07bb 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,17 +1,7 @@ -import { - loadJobConfig, - registerCreateAction, - registerStatusUpdateAction, -} from "../jobs/config/jobconfig"; -import { LogJobAction } from "../jobs/actions/logaction"; -import { EmailJobAction } from "../jobs/actions/emailaction"; -import { URLAction } from "src/jobs/actions/urlaction"; -import { RabbitMQJobAction } from "src/jobs/actions/rabbitmqaction"; import * as fs from "fs"; import { merge } from "lodash"; import localconfiguration from "./localconfiguration"; import { boolean } from "mathjs"; -import { ValidateAction } from "src/jobs/actions/validateaction"; import { DEFAULT_PROPOSAL_TYPE } from "src/proposals/schemas/proposal.schema"; const configuration = () => { @@ -46,7 +36,7 @@ const configuration = () => { process.env.OIDC_USERINFO_MAPPING_FIELD_USERNAME || ("" as string); const jobConfigurationFile = - process.env.JOB_CONFIGURATION_FILE || ("jobConfig.yaml" as string); + process.env.JOB_CONFIGURATION_FILE || ("" as string); const defaultLogger = { type: "DefaultLogger", @@ -75,9 +65,6 @@ const configuration = () => { } }); - registerDefaultActions(); - const job_configs = loadJobConfig(jobConfigurationFile); - // NOTE: Add the default proposal type here Object.assign(jsonConfigMap.proposalTypes, { DefaultProposal: DEFAULT_PROPOSAL_TYPE, @@ -89,7 +76,7 @@ const configuration = () => { api: "3", }, swaggerPath: process.env.SWAGGER_PATH || "explorer", - jobConfiguration: job_configs, + jobConfigurationFile: jobConfigurationFile, loggerConfigs: jsonConfigMap.loggers || [defaultLogger], adminGroups: adminGroups.split(",").map((v) => v.trim()) ?? [], deleteGroups: deleteGroups.split(",").map((v) => v.trim()) ?? [], @@ -232,23 +219,6 @@ const configuration = () => { return merge(config, localconfiguration); }; -/** - * Registers built-in JobActions. Should be called exactly once. - */ -export function registerDefaultActions() { - // Create - registerCreateAction(LogJobAction); - registerCreateAction(EmailJobAction); - registerCreateAction(URLAction); - registerCreateAction(RabbitMQJobAction); - registerCreateAction(ValidateAction); - // Status Update - registerStatusUpdateAction(LogJobAction); - registerStatusUpdateAction(EmailJobAction); - registerStatusUpdateAction(RabbitMQJobAction); - registerStatusUpdateAction(ValidateAction); -} - export type OidcConfig = ReturnType["oidc"]; export default configuration; diff --git a/src/config/job-config/README.md b/src/config/job-config/README.md new file mode 100644 index 000000000..ad7cf0991 --- /dev/null +++ b/src/config/job-config/README.md @@ -0,0 +1,33 @@ +# Job Configuration + +## Background + +Jobs are SciCat's main way of interacting with external systems. Thus the actions which +should be performed when a job is created or updated (with POST or PATCH requests; see +[job.controller.ts](../../jobs/job.controller.ts)) tend to be facility specific. To +facilitate this, the allowed jobs are configured in a YAML or JSON file. An example file +is available at [jobConfig.example.yaml](../../../jobConfig.example.yaml). The location +of the job config file is configurable in with the `JOB_CONFIGURATION_FILE` environment +variable. + +The file is parsed when the application starts and converted to a `JobConfigService` +instance. Job types are arbitrary and facility-specific, but `archive` and `retrieve` +are traditional for interacting with the data storage system. Authorization can be +configured for each job type for *create* and *update* requests, and then a list of +actions are provided which should run after the request. + +## Implementing an Action + +Implementing an Action requires four (short) files: +1. `action.ts` contains a class implementing `JobAction`. It's constructor can take any arguments, but the existing actions take an `options` argument mirroring the expected yaml config. It does not need to be `@Injectable()`, since it is constructed by the factory. +2. `action.interface.ts` can contain additional types, e.g. the definition of the expected `options` and a type guard for casting to the options. +3. `action.factory.ts` implements `JobActionFactory`. The factory is provided by NestJS as a singleton, so it must be `@Injectable()`. This means that dependencies can be injected into the factory. It has a `create(options)` method, which constructs the action itself by combining the options from the yaml file with any dependencies injected by NestJS. +4. `action.module.ts` is an NestJS module that provides the factory. + +The lists of known factories are provided to Nest with the `CREATE_JOB_ACTION_FACTORIES` and `UPDATE_JOB_ACTION_FACTORIES` symbols. The top-level AppModule imports built-in actions from `DefaultJobActionFactories`. Core actions should be added to this list. Plugins can use the NestJS dependency injection system to extend the lists if needed. + +## Accessing the jobConfig + +Parsing `jobConfig.yaml` is handled by the JobConfigService. NestJS injects the lists of factories (and the file path) during construction. When parsing reaches a new action, the correct factory is used to create an instance of the JobAction with the current list of options. + +Code which needs the configuration for a particular job type (eg jobs.controller.ts) injects the `JobConfigService` and can then call `jobConfigService.get(jobType)`. diff --git a/src/config/job-config/actions/defaultjobactions.module.ts b/src/config/job-config/actions/defaultjobactions.module.ts new file mode 100644 index 000000000..24c358545 --- /dev/null +++ b/src/config/job-config/actions/defaultjobactions.module.ts @@ -0,0 +1,90 @@ +import { Module } from "@nestjs/common"; +import { LogJobActionFactory } from "./logaction/logaction.factory"; +import { LogJobActionModule } from "./logaction/logaction.module"; +import { EmailJobActionFactory } from "./emailaction/emailaction.factory"; +import { EmailJobActionModule } from "./emailaction/emailaction.module"; +import { actionType as logActionType } from "./logaction/logaction.interface"; +import { actionType as emailActionType } from "./emailaction/emailaction.interface"; +import { ValidateJobActionModule } from "./validateaction/validateaction.module"; +import { actionType as validateActionType } from "./validateaction/validateaction.interface"; +import { ValidateJobActionFactory } from "./validateaction/validateaction.factory"; +import { URLJobActionModule } from "./urlaction/urlaction.module"; +import { URLJobActionFactory } from "./urlaction/urlaction.factory"; +import { actionType as urlActionType } from "./urlaction/urlaction.interface"; +import { RabbitMQJobActionModule } from "./rabbitmqaction/rabbitmqaction.module"; +import { actionType as rabbitmqActionType } from "./rabbitmqaction/rabbitmqaction.interface"; +import { RabbitMQJobActionFactory } from "./rabbitmqaction/rabbitmqaction.factory"; +import { + CREATE_JOB_ACTION_FACTORIES, + UPDATE_JOB_ACTION_FACTORIES, +} from "../jobconfig.interface"; + +/** + * Provide a list of built-in job action factories. + * + * CREATE_JOB_ACTION_FACTORIES and UPDATE_JOB_ACTION_FACTORIES be extended (eg by a + * plugin) with additional factories. + */ +@Module({ + imports: [ + EmailJobActionModule, + LogJobActionModule, + ValidateJobActionModule, + URLJobActionModule, + RabbitMQJobActionModule, + ], + providers: [ + { + provide: CREATE_JOB_ACTION_FACTORIES, + useFactory: ( + logJobActionFactory, + emailJobActionFactory, + validateJobActionFactory, + urlJobActionFactory, + rabbitMQJobActionFactory, + ) => { + return { + [logActionType]: logJobActionFactory, + [emailActionType]: emailJobActionFactory, + [validateActionType]: validateJobActionFactory, + [urlActionType]: urlJobActionFactory, + [rabbitmqActionType]: rabbitMQJobActionFactory, + }; + }, + inject: [ + LogJobActionFactory, + EmailJobActionFactory, + ValidateJobActionFactory, + URLJobActionFactory, + RabbitMQJobActionFactory, + ], + }, + { + provide: UPDATE_JOB_ACTION_FACTORIES, + useFactory: ( + logJobActionFactory, + emailJobActionFactory, + validateJobActionFactory, + urlJobActionFactory, + rabbitMQJobActionFactory, + ) => { + return { + [logActionType]: logJobActionFactory, + [emailActionType]: emailJobActionFactory, + [validateActionType]: validateJobActionFactory, + [urlActionType]: urlJobActionFactory, + [rabbitmqActionType]: rabbitMQJobActionFactory, + }; + }, + inject: [ + LogJobActionFactory, + EmailJobActionFactory, + ValidateJobActionFactory, + URLJobActionFactory, + RabbitMQJobActionFactory, + ], + }, + ], + exports: [CREATE_JOB_ACTION_FACTORIES, UPDATE_JOB_ACTION_FACTORIES], +}) +export class DefaultJobActionFactories {} diff --git a/src/config/job-config/actions/emailaction/emailaction.factory.ts b/src/config/job-config/actions/emailaction/emailaction.factory.ts new file mode 100644 index 000000000..70b4ad5e3 --- /dev/null +++ b/src/config/job-config/actions/emailaction/emailaction.factory.ts @@ -0,0 +1,21 @@ +import { Injectable } from "@nestjs/common"; +import { + JobActionFactory, + JobActionOptions, + JobDto, +} from "../../jobconfig.interface"; +import { isEmailJobActionOptions } from "../emailaction/emailaction.interface"; +import { EmailJobAction } from "./emailaction"; +import { MailService } from "src/common/mail.service"; + +@Injectable() +export class EmailJobActionFactory implements JobActionFactory { + constructor(private mailService: MailService) {} + + public create(options: Options) { + if (!isEmailJobActionOptions(options)) { + throw new Error("Invalid options for EmailJobAction."); + } + return new EmailJobAction(this.mailService, options); + } +} diff --git a/src/config/job-config/actions/emailaction/emailaction.interface.ts b/src/config/job-config/actions/emailaction/emailaction.interface.ts new file mode 100644 index 000000000..fc14f2b52 --- /dev/null +++ b/src/config/job-config/actions/emailaction/emailaction.interface.ts @@ -0,0 +1,31 @@ +import { JobActionOptions } from "../../jobconfig.interface"; + +export const actionType = "email"; + +export interface EmailJobActionOptions extends JobActionOptions { + actionType: typeof actionType; + to: string; + from: string; + subject: string; + bodyTemplateFile: string; +} + +/** + * Type guard for EmailJobActionOptions + */ +export function isEmailJobActionOptions( + options: unknown, +): options is EmailJobActionOptions { + if (typeof options !== "object" || options === null) { + return false; + } + + const opts = options as EmailJobActionOptions; + return ( + opts.actionType === actionType && + typeof opts.to === "string" && + typeof opts.from === "string" && + typeof opts.subject === "string" && + typeof opts.bodyTemplateFile === "string" + ); +} diff --git a/src/config/job-config/actions/emailaction/emailaction.module.ts b/src/config/job-config/actions/emailaction/emailaction.module.ts new file mode 100644 index 000000000..81436f5f6 --- /dev/null +++ b/src/config/job-config/actions/emailaction/emailaction.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { EmailJobActionFactory } from "./emailaction.factory"; +import { CommonModule } from "src/common/common.module"; + +@Module({ + imports: [CommonModule], + providers: [EmailJobActionFactory], + exports: [EmailJobActionFactory], +}) +export class EmailJobActionModule {} diff --git a/src/config/job-config/actions/emailaction/emailaction.ts b/src/config/job-config/actions/emailaction/emailaction.ts new file mode 100644 index 000000000..3cf3d0ed9 --- /dev/null +++ b/src/config/job-config/actions/emailaction/emailaction.ts @@ -0,0 +1,68 @@ +/** + * Send emails in response to job events + * This is intended as an example of the JobAction interface + * + */ +import { readFileSync } from "fs"; +import { compileJob, TemplateJob } from "../../handlebar-utils"; +import { Logger } from "@nestjs/common"; +import { JobAction, JobDto } from "../../jobconfig.interface"; +import { JobClass } from "../../../../jobs/schemas/job.schema"; +import { ISendMailOptions } from "@nestjs-modules/mailer"; +import { actionType, EmailJobActionOptions } from "./emailaction.interface"; +import { MailService } from "src/common/mail.service"; + +/** + * Send an email following a job + */ +export class EmailJobAction implements JobAction { + private toTemplate: TemplateJob; + private from: string; + private subjectTemplate: TemplateJob; + private bodyTemplate: TemplateJob; + + getActionType(): string { + return actionType; + } + + constructor( + private mailService: MailService, + options: EmailJobActionOptions, + ) { + Logger.log( + "Initializing EmailJobAction. Params: " + JSON.stringify(options), + "EmailJobAction", + ); + + Logger.log("EmailJobAction parameters are valid.", "EmailJobAction"); + + this.from = options.from as string; + this.toTemplate = compileJob(options.to); + this.subjectTemplate = compileJob(options.subject); + + const templateFile = readFileSync( + options["bodyTemplateFile"] as string, + "utf8", + ); + this.bodyTemplate = compileJob(templateFile); + } + + async performJob(job: JobClass) { + Logger.log( + "Performing EmailJobAction: " + JSON.stringify(job), + "EmailJobAction", + ); + + // Fill templates + const mail: ISendMailOptions = { + to: this.toTemplate(job), + from: this.from, + subject: this.subjectTemplate(job), + }; + mail.text = this.bodyTemplate(job); + Logger.log(mail); + + // Send the email + await this.mailService.sendMail(mail); + } +} diff --git a/src/config/job-config/actions/logaction/logaction.factory.ts b/src/config/job-config/actions/logaction/logaction.factory.ts new file mode 100644 index 000000000..2c6dcf388 --- /dev/null +++ b/src/config/job-config/actions/logaction/logaction.factory.ts @@ -0,0 +1,21 @@ +import { isLogJobActionOptions } from "./logaction.interface"; +import { + JobActionFactory, + JobActionOptions, + JobDto, +} from "../../jobconfig.interface"; +import { Injectable } from "@nestjs/common"; +import { LogJobAction } from "./logaction"; + +@Injectable() +export class LogJobActionFactory implements JobActionFactory { + constructor() {} + + public create(options: JobActionOptions) { + if (!isLogJobActionOptions(options)) { + throw new Error("Invalid options for LogJobAction."); + } + + return new LogJobAction(options); + } +} diff --git a/src/config/job-config/actions/logaction/logaction.interface.ts b/src/config/job-config/actions/logaction/logaction.interface.ts new file mode 100644 index 000000000..d5a4941bd --- /dev/null +++ b/src/config/job-config/actions/logaction/logaction.interface.ts @@ -0,0 +1,28 @@ +export const actionType = "log"; + +export interface LogJobActionOptions { + actionType: typeof actionType; + init?: string; + validate?: string; + performJob?: string; +} + +/** + * Type guard for LogJobActionOptions + */ +export function isLogJobActionOptions( + options: unknown, +): options is LogJobActionOptions { + if (typeof options !== "object" || options === null) { + return false; + } + + const opts = options as LogJobActionOptions; + + return ( + opts.actionType === actionType && + (opts.init === undefined || typeof opts.init === "string") && + (opts.validate === undefined || typeof opts.validate === "string") && + (opts.performJob === undefined || typeof opts.performJob === "string") + ); +} diff --git a/src/config/job-config/actions/logaction/logaction.module.ts b/src/config/job-config/actions/logaction/logaction.module.ts new file mode 100644 index 000000000..606bdb5f1 --- /dev/null +++ b/src/config/job-config/actions/logaction/logaction.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { LogJobActionFactory } from "./logaction.factory"; + +@Module({ + providers: [LogJobActionFactory], + exports: [LogJobActionFactory], +}) +export class LogJobActionModule {} diff --git a/src/config/job-config/actions/logaction/logaction.ts b/src/config/job-config/actions/logaction/logaction.ts new file mode 100644 index 000000000..ae4d43140 --- /dev/null +++ b/src/config/job-config/actions/logaction/logaction.ts @@ -0,0 +1,47 @@ +/** + * Simple JobAction for logging events. + */ +import { Logger } from "@nestjs/common"; +import { JobAction, JobDto } from "../../jobconfig.interface"; +import { JobClass } from "../../../../jobs/schemas/job.schema"; +import { LogJobActionOptions, actionType } from "./logaction.interface"; +import { compile, TemplateDelegate } from "handlebars"; +import { compileJob, TemplateJob } from "../../handlebar-utils"; + +export class LogJobAction implements JobAction { + private validateTemplate: TemplateDelegate; + private performJobTemplate: TemplateJob; + + getActionType(): string { + return actionType; + } + + constructor(options: LogJobActionOptions) { + options = { + init: "", + validate: "Validating job dto for {{{type}}}", + performJob: "Performing job for {{{type}}}", + ...options, + }; + + const initTemplate = compile(options.init); + this.validateTemplate = compile(options.validate || ""); + this.performJobTemplate = compileJob(options.performJob || ""); + + const msg = initTemplate(options); + if (msg) { + Logger.log(msg, "LogJobAction"); + } + } + + async validate(dto: T) { + const msg = this.validateTemplate(dto); + if (msg) { + Logger.log(msg, "LogJobAction"); + } + } + + async performJob(job: JobClass) { + Logger.log(this.performJobTemplate(job), "LogJobAction"); + } +} diff --git a/src/config/job-config/actions/rabbitmqaction/rabbitmqaction.factory.ts b/src/config/job-config/actions/rabbitmqaction/rabbitmqaction.factory.ts new file mode 100644 index 000000000..bcbfb110a --- /dev/null +++ b/src/config/job-config/actions/rabbitmqaction/rabbitmqaction.factory.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@nestjs/common"; +import { + JobActionFactory, + JobActionOptions, + JobDto, +} from "../../jobconfig.interface"; +import { isRabbitMQJobActionOptions } from "../rabbitmqaction/rabbitmqaction.interface"; +import { RabbitMQJobAction } from "./rabbitmqaction"; + +@Injectable() +export class RabbitMQJobActionFactory implements JobActionFactory { + constructor() {} + + public create(options: Options) { + if (!isRabbitMQJobActionOptions(options)) { + throw new Error("Invalid options for RabbitMQJobAction."); + } + return new RabbitMQJobAction(options); + } +} diff --git a/src/config/job-config/actions/rabbitmqaction/rabbitmqaction.interface.ts b/src/config/job-config/actions/rabbitmqaction/rabbitmqaction.interface.ts new file mode 100644 index 000000000..c7a310bc5 --- /dev/null +++ b/src/config/job-config/actions/rabbitmqaction/rabbitmqaction.interface.ts @@ -0,0 +1,37 @@ +import { JobActionOptions } from "../../jobconfig.interface"; + +export const actionType = "rabbitmq"; + +export interface RabbitMQJobActionOptions extends JobActionOptions { + actionType: typeof actionType; + hostname: string; + port: number; + username: string; + password: string; + exchange: string; + queue: string; + key: string; +} + +/** + * Type guard for RabbitMQJobActionOptions + */ +export function isRabbitMQJobActionOptions( + options: unknown, +): options is RabbitMQJobActionOptions { + if (typeof options !== "object" || options === null) { + return false; + } + + const opts = options as RabbitMQJobActionOptions; + return ( + opts.actionType === actionType && + typeof opts.hostname === "string" && + typeof opts.port === "number" && + typeof opts.username === "string" && + typeof opts.password === "string" && + typeof opts.exchange === "string" && + typeof opts.queue === "string" && + typeof opts.key === "string" + ); +} diff --git a/src/config/job-config/actions/rabbitmqaction/rabbitmqaction.module.ts b/src/config/job-config/actions/rabbitmqaction/rabbitmqaction.module.ts new file mode 100644 index 000000000..97fec2954 --- /dev/null +++ b/src/config/job-config/actions/rabbitmqaction/rabbitmqaction.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { RabbitMQJobActionFactory } from "./rabbitmqaction.factory"; +import { CommonModule } from "src/common/common.module"; + +@Module({ + imports: [CommonModule], + providers: [RabbitMQJobActionFactory], + exports: [RabbitMQJobActionFactory], +}) +export class RabbitMQJobActionModule {} diff --git a/src/jobs/actions/rabbitmqaction.ts b/src/config/job-config/actions/rabbitmqaction/rabbitmqaction.ts similarity index 63% rename from src/jobs/actions/rabbitmqaction.ts rename to src/config/job-config/actions/rabbitmqaction/rabbitmqaction.ts index 1e61fa5b9..dce4eb735 100644 --- a/src/jobs/actions/rabbitmqaction.ts +++ b/src/config/job-config/actions/rabbitmqaction/rabbitmqaction.ts @@ -1,7 +1,11 @@ -import { Logger, NotFoundException } from "@nestjs/common"; +import { Logger } from "@nestjs/common"; import amqp, { Connection } from "amqplib/callback_api"; -import { JobAction, JobDto } from "../config/jobconfig"; -import { JobClass } from "../schemas/job.schema"; +import { JobAction, JobDto } from "../../jobconfig.interface"; +import { JobClass } from "../../../../jobs/schemas/job.schema"; +import { + actionType, + RabbitMQJobActionOptions, +} from "./rabbitmqaction.interface"; /** * Publish a message in a RabbitMQ queue @@ -11,47 +15,28 @@ export class RabbitMQJobAction implements JobAction { private connection; private binding; - constructor(data: Record) { + constructor(options: RabbitMQJobActionOptions) { Logger.log( - "Initializing RabbitMQJobAction. Params: " + JSON.stringify(data), + "Initializing RabbitMQJobAction. Params: " + JSON.stringify(options), "RabbitMQJobAction", ); - // Validate that all necessary params are present - const requiredConnectionParams = [ - "hostname", - "port", - "username", - "password", - ]; - for (const param of requiredConnectionParams) { - if (!data[param]) { - throw new NotFoundException(`Missing connection parameter: ${param}`); - } - } - - const requiredBindingParams = ["exchange", "queue", "key"]; - for (const param of requiredBindingParams) { - if (!data[param]) { - throw new NotFoundException(`Missing binding parameter: ${param}`); - } - } this.connection = { protocol: "amqp", - hostname: data.hostname as string, - port: data.port as number, - username: data.username as string, - password: data.password as string, + hostname: options.hostname, + port: options.port, + username: options.username, + password: options.password, }; this.binding = { - exchange: data.exchange as string, - queue: data.queue as string, - key: data.key as string, + exchange: options.exchange, + queue: options.queue, + key: options.key, }; } getActionType(): string { - return RabbitMQJobAction.actionType; + return actionType; } async performJob(job: JobClass) { diff --git a/src/config/job-config/actions/urlaction/urlaction.factory.ts b/src/config/job-config/actions/urlaction/urlaction.factory.ts new file mode 100644 index 000000000..0592a69de --- /dev/null +++ b/src/config/job-config/actions/urlaction/urlaction.factory.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@nestjs/common"; +import { + JobActionFactory, + JobActionOptions, + JobDto, +} from "../../jobconfig.interface"; +import { URLJobAction } from "./urlaction"; +import { isURLJobActionOptions } from "./urlaction.interface"; + +@Injectable() +export class URLJobActionFactory implements JobActionFactory { + constructor() {} + + public create(options: Options) { + if (!isURLJobActionOptions(options)) { + throw new Error("Invalid options for URLJobAction."); + } + return new URLJobAction(options); + } +} diff --git a/src/config/job-config/actions/urlaction/urlaction.interface.ts b/src/config/job-config/actions/urlaction/urlaction.interface.ts new file mode 100644 index 000000000..677abf209 --- /dev/null +++ b/src/config/job-config/actions/urlaction/urlaction.interface.ts @@ -0,0 +1,45 @@ +export const actionType = "url"; + +export interface URLJobActionOptions { + actionType: typeof actionType; + url: string; + method?: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + body?: unknown; +} + +/** + * Type guard for EmailJobActionOptions + */ +export function isURLJobActionOptions( + options: unknown, +): options is URLJobActionOptions { + if (typeof options !== "object" || options === null) { + return false; + } + const opts = options as URLJobActionOptions; + + return ( + opts.actionType === actionType && + typeof opts.url === "string" && + (opts.method === undefined || + ["GET", "POST", "PUT", "DELETE"].includes(opts.method)) && + (opts.headers === undefined || isStringRecord(opts.headers)) + ); +} + +/** + * Type guard for Record + * @param obj + * @returns + */ +function isStringRecord(obj: unknown): obj is Record { + if (typeof obj !== "object" || obj === null) { + return false; + } + const rec = obj as Record; + + return Object.keys(rec).every( + (key) => typeof key === "string" && typeof rec[key] === "string", + ); +} diff --git a/src/config/job-config/actions/urlaction/urlaction.module.ts b/src/config/job-config/actions/urlaction/urlaction.module.ts new file mode 100644 index 000000000..7715f2ba8 --- /dev/null +++ b/src/config/job-config/actions/urlaction/urlaction.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { URLJobActionFactory } from "./urlaction.factory"; + +@Module({ + providers: [URLJobActionFactory], + exports: [URLJobActionFactory], +}) +export class URLJobActionModule {} diff --git a/src/config/job-config/actions/urlaction/urlaction.ts b/src/config/job-config/actions/urlaction/urlaction.ts new file mode 100644 index 000000000..a3d8b52e5 --- /dev/null +++ b/src/config/job-config/actions/urlaction/urlaction.ts @@ -0,0 +1,83 @@ +import { Logger, HttpException } from "@nestjs/common"; +import { JobAction, JobDto } from "../../jobconfig.interface"; +import { JobClass } from "../../../../jobs/schemas/job.schema"; +import { compileJob, TemplateJob } from "../../handlebar-utils"; +import { actionType, URLJobActionOptions } from "./urlaction.interface"; + +/** + * Respond to Job events by making an HTTP call. + */ +export class URLJobAction implements JobAction { + private urlTemplate: TemplateJob; + private method = "GET"; + private headerTemplates?: Record = {}; + private bodyTemplate?: TemplateJob; + + getActionType(): string { + return actionType; + } + + async performJob(job: JobClass) { + const url = encodeURI(this.urlTemplate(job)); + Logger.log(`Requesting ${url}`, "URLAction"); + + const response = await fetch(url, { + method: this.method, + headers: this.headerTemplates + ? Object.fromEntries( + Object.entries(this.headerTemplates).map(([key, template]) => [ + key, + template(job), + ]), + ) + : undefined, + body: this.bodyTemplate ? this.bodyTemplate(job) : undefined, + }); + + Logger.log(`Request for ${url} returned ${response.status}`, "URLAction"); + if (!response.ok) { + throw new HttpException( + { + status: response.status, + message: `Got response: ${await response.text()}`, + }, + response.status, + ); + } + + // TODO do something with the response? + } + + /** + * Constructor for the class. + * + * @param {Record} options - The data object should contain the following properties: + * - url (required): the URL for the request + * - method (optional): the HTTP method for the request, e.g. "GET", "POST" + * - headers (optional): an object containing HTTP headers to be included in the request + * - body (optional): the body of the request, for methods like "POST" or "PUT" + * + * @throws {NotFoundException} If the 'url' parameter is not provided in the data object + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(options: URLJobActionOptions) { + this.urlTemplate = compileJob(options.url); + + if (options["method"]) { + this.method = options.method; + } + + if (options["headers"]) { + this.headerTemplates = Object.fromEntries( + Object.entries(options.headers).map(([key, value]) => [ + key, + compileJob(value), + ]), + ); + } + + if (options["body"]) { + this.bodyTemplate = compileJob(options["body"]); + } + } +} diff --git a/src/config/job-config/actions/validateaction/validateaction.factory.ts b/src/config/job-config/actions/validateaction/validateaction.factory.ts new file mode 100644 index 000000000..1203e17ae --- /dev/null +++ b/src/config/job-config/actions/validateaction/validateaction.factory.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@nestjs/common"; +import { + JobActionFactory, + JobActionOptions, + JobDto, +} from "../../jobconfig.interface"; +import { ValidateJobAction } from "./validateaction"; +import { isValidateJobActionOptions } from "./validateaction.interface"; + +@Injectable() +export class ValidateJobActionFactory implements JobActionFactory { + constructor() {} + + public create(options: Options) { + if (!isValidateJobActionOptions(options)) { + throw new Error("Invalid options for ValidateJobAction."); + } + return new ValidateJobAction(options); + } +} diff --git a/src/config/job-config/actions/validateaction/validateaction.interface.ts b/src/config/job-config/actions/validateaction/validateaction.interface.ts new file mode 100644 index 000000000..aea837f2c --- /dev/null +++ b/src/config/job-config/actions/validateaction/validateaction.interface.ts @@ -0,0 +1,21 @@ +import { JobActionOptions } from "../../jobconfig.interface"; + +export const actionType = "validate"; + +export interface ValidateJobActionOptions extends JobActionOptions { + actionType: typeof actionType; + request: Record; +} + +/** + * Type guard for EmailJobActionOptions + */ +export function isValidateJobActionOptions( + options: unknown, +): options is ValidateJobActionOptions { + if (typeof options === "object" && options !== null) { + const opts = options as ValidateJobActionOptions; + return opts.actionType === actionType && typeof opts.request === "object"; + } + return false; +} diff --git a/src/config/job-config/actions/validateaction/validateaction.module.ts b/src/config/job-config/actions/validateaction/validateaction.module.ts new file mode 100644 index 000000000..84a2b9903 --- /dev/null +++ b/src/config/job-config/actions/validateaction/validateaction.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { ValidateJobActionFactory } from "./validateaction.factory"; + +@Module({ + providers: [ValidateJobActionFactory], + exports: [ValidateJobActionFactory], +}) +export class ValidateJobActionModule {} diff --git a/src/jobs/actions/validateaction.spec.ts b/src/config/job-config/actions/validateaction/validateaction.spec.ts similarity index 91% rename from src/jobs/actions/validateaction.spec.ts rename to src/config/job-config/actions/validateaction/validateaction.spec.ts index 2897f595b..fddf96200 100644 --- a/src/jobs/actions/validateaction.spec.ts +++ b/src/config/job-config/actions/validateaction/validateaction.spec.ts @@ -1,5 +1,6 @@ -import { ValidateAction } from "./validateaction"; -import { CreateJobDto } from "../dto/create-job.dto"; +import { ValidateJobAction } from "./validateaction"; +import { CreateJobDto } from "../../../../jobs/dto/create-job.dto"; +import { ValidateJobActionOptions } from "./validateaction.interface"; const createJobBase = { type: "validate", @@ -9,7 +10,7 @@ const createJobBase = { }; describe("ValiateAction", () => { - const config = { + const config: ValidateJobActionOptions = { actionType: "validate", request: { "jobParams.stringVal": { type: "string" }, @@ -18,7 +19,7 @@ describe("ValiateAction", () => { jobParams: { required: ["nonNull"] }, }, }; - const action = new ValidateAction(config); + const action = new ValidateJobAction(config); it("should be configured successfully", async () => { expect(action).toBeDefined(); }); diff --git a/src/jobs/actions/validateaction.ts b/src/config/job-config/actions/validateaction/validateaction.ts similarity index 85% rename from src/jobs/actions/validateaction.ts rename to src/config/job-config/actions/validateaction/validateaction.ts index 2dde5821c..a7182fee2 100644 --- a/src/jobs/actions/validateaction.ts +++ b/src/config/job-config/actions/validateaction/validateaction.ts @@ -1,8 +1,12 @@ import { HttpException, HttpStatus, NotFoundException } from "@nestjs/common"; -import { JobAction, JobDto } from "../config/jobconfig"; -import { JobClass } from "../schemas/job.schema"; +import { JobAction, JobDto } from "../../jobconfig.interface"; +import { JobClass } from "../../../../jobs/schemas/job.schema"; import { JSONPath } from "jsonpath-plus"; import Ajv, { ValidateFunction } from "ajv"; +import { + actionType, + ValidateJobActionOptions, +} from "./validateaction.interface"; /** * Validates the job DTO for the presence of required fields. Can also check types or @@ -32,12 +36,34 @@ import Ajv, { ValidateFunction } from "ajv"; * } * } */ -export class ValidateAction implements JobAction { - public static readonly actionType = "validate"; +export class ValidateJobAction implements JobAction { private request: Record>; getActionType(): string { - return ValidateAction.actionType; + return actionType; + } + + constructor(options: ValidateJobActionOptions) { + if (!("request" in options)) { + throw new NotFoundException( + `Missing connection parameter in 'validate' action: 'request'`, + ); + } + const request = options["request"] as Record; + + const ajv = new Ajv({ + strictSchema: false, + strictTypes: false, + }); + this.request = Object.fromEntries( + Object.entries(request).map(([path, schema]) => { + if (typeof schema !== "object" || schema === null) { + throw new Error("Schema must be a valid object."); + } + + return [path, ajv.compile(schema)]; + }), + ); } async validate(dto: T) { @@ -73,27 +99,4 @@ export class ValidateAction implements JobAction { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars async performJob(job: JobClass) {} - - constructor(data: Record) { - if (!("request" in data)) { - throw new NotFoundException( - `Missing connection parameter in 'validate' action: 'request'`, - ); - } - const request = data["request"] as Record; - - const ajv = new Ajv({ - strictSchema: false, - strictTypes: false, - }); - this.request = Object.fromEntries( - Object.entries(request).map(([path, schema]) => { - if (typeof schema !== "object" || schema === null) { - throw new Error("Schema must be a valid object."); - } - - return [path, ajv.compile(schema)]; - }), - ); - } } diff --git a/src/config/job-config/handlebar-utils.ts b/src/config/job-config/handlebar-utils.ts new file mode 100644 index 000000000..3c6334d37 --- /dev/null +++ b/src/config/job-config/handlebar-utils.ts @@ -0,0 +1,30 @@ +import * as hb from "handlebars"; +import { JobClass } from "src/jobs/schemas/job.schema"; + +const jobTemplateOptions = { + allowedProtoProperties: { + id: true, + type: true, + statusCode: true, + statusMessage: true, + createdBy: true, + jobParams: true, + contactEmail: true, + }, + allowProtoPropertiesByDefault: false, // limit accessible fields for security +}; + +export type TemplateJob = hb.TemplateDelegate; + +export function compileJob( + input: unknown, + options?: CompileOptions, +): TemplateJob { + const template: TemplateJob = hb.compile(input, options); + return (context: JobClass, options?: RuntimeOptions) => { + return template(context, { + ...jobTemplateOptions, + ...options, + }); + }; +} diff --git a/src/config/job-config/jobconfig.interface.ts b/src/config/job-config/jobconfig.interface.ts new file mode 100644 index 000000000..b5be29665 --- /dev/null +++ b/src/config/job-config/jobconfig.interface.ts @@ -0,0 +1,85 @@ +import { CreateJobDto } from "../../jobs/dto/create-job.dto"; +import { StatusUpdateJobDto } from "../../jobs/dto/status-update-job.dto"; +import { JobsAuth } from "../../jobs/types/jobs-auth.enum"; +import { JobClass } from "../../jobs/schemas/job.schema"; + +// Nest Token for CREATE job actions +export const CREATE_JOB_ACTION_FACTORIES = Symbol( + "CREATE_JOB_ACTION_FACTORIES", +); +// Nest Token for UPDATE job actions +export const UPDATE_JOB_ACTION_FACTORIES = Symbol( + "UPDATE_JOB_ACTION_FACTORIES", +); + +export interface JobConfigListOptions { + configVersion?: string; + jobs: JobConfigOptions[]; +} + +/** + * Encapsulates all responses to a particular job type (eg "archive") + */ +export interface JobConfig { + jobType: string; + configVersion: string; + create: JobOperation; + statusUpdate: JobOperation; +} +export interface JobConfigOptions { + jobType: string; + configVersion?: string; + create: JobOperationOptions; + statusUpdate: JobOperationOptions; +} + +export type JobDto = CreateJobDto | StatusUpdateJobDto; +/** + * Encapsulates all information for a particular job operation (eg "create", "statusUpdate") + */ +export interface JobOperation { + auth: JobsAuth | undefined; + actions: JobAction[]; +} +export interface JobOperationOptions { + auth: JobsAuth | undefined; + actions?: JobActionOptions[]; +} + +/** + * Superclass for all responses to Job changes + */ +export interface JobAction { + /** + * Validate that the request body for this job operation. + * + * Note that the configuration of this action is validated in the constructor. + * Actions that don't need custom DTO methods can omit this method. + * + * @param dto data transfer object received from the client + * @throw HttpException if the DTO is invalid + * @returns + */ + validate?: (dto: DtoType) => Promise; + + /** + * Respond to the action + */ + performJob: (job: JobClass) => Promise; + + /** + * Return the actionType for this action. This should match the class's + * static actionType (used for constructing the class from the configuration file) + */ + getActionType(): string; +} +export interface JobActionOptions { + actionType: string; +} + +/** + * Represents a class that can create a JobAction after reading the jobConfigurationFile + */ +export type JobActionFactory = { + create: (options: JobActionOptions) => JobAction; +}; diff --git a/src/config/job-config/jobconfig.module.ts b/src/config/job-config/jobconfig.module.ts new file mode 100644 index 000000000..af61054f3 --- /dev/null +++ b/src/config/job-config/jobconfig.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { JobConfigService } from "./jobconfig.service"; +import { ConfigModule } from "@nestjs/config"; +import { DefaultJobActionFactories } from "./actions/defaultjobactions.module"; + +@Module({ + imports: [ConfigModule, DefaultJobActionFactories], + providers: [JobConfigService], + exports: [JobConfigService], +}) +export class JobConfigModule {} diff --git a/src/jobs/config/jobConfig.schema.ts b/src/config/job-config/jobconfig.schema.ts similarity index 100% rename from src/jobs/config/jobConfig.schema.ts rename to src/config/job-config/jobconfig.schema.ts diff --git a/src/config/job-config/jobconfig.service.ts b/src/config/job-config/jobconfig.service.ts new file mode 100644 index 000000000..7c2a772cf --- /dev/null +++ b/src/config/job-config/jobconfig.service.ts @@ -0,0 +1,132 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { + CREATE_JOB_ACTION_FACTORIES, + JobActionFactory, + JobConfig, + JobConfigListOptions, + JobConfigOptions, + JobDto, + JobOperation, + JobOperationOptions, + UPDATE_JOB_ACTION_FACTORIES, +} from "./jobconfig.interface"; +import Ajv from "ajv"; +import { JobConfigSchema } from "./jobconfig.schema"; +import { load } from "js-yaml"; +import * as fs from "fs"; +import { CreateJobDto } from "../../jobs/dto/create-job.dto"; +import { StatusUpdateJobDto } from "../../jobs/dto/status-update-job.dto"; +import { ConfigService } from "@nestjs/config"; + +/** + * Service representing a parsed jobconfig.yaml file. + */ +@Injectable() +export class JobConfigService { + private readonly jobs: Record; + private readonly filePath: string; + + constructor( + @Inject(CREATE_JOB_ACTION_FACTORIES) + private create_factories: Record>, + @Inject(UPDATE_JOB_ACTION_FACTORIES) + private update_factories: Record< + string, + JobActionFactory + >, + configService: ConfigService, + ) { + this.filePath = configService.get("jobConfigurationFile") || ""; + this.jobs = this.loadJobConfig(this.filePath); + } + + public get(jobType: string) { + return this.jobs[jobType]; // TODO error handling + } + + public get allJobConfigs(): Readonly> { + return this.jobs; + } + + /** + * Load jobconfig.yaml (or json) file. + * Expects one or more JobConfig configurations (see JobConfig.parse) + * @param filePath path to json config file + * @returns + */ + private loadJobConfig(filePath: string): Record { + // If no file is given don't configure any jobs + if (!filePath) { + return {}; + } + const yaml = fs.readFileSync(filePath, "utf8"); + const jobConfigListOptions = load(yaml, { filename: filePath }); + + // TODO: Do we like the schema-based validation, or should it be pure typescript? + const ajv = new Ajv(); + const validate = ajv.compile(JobConfigSchema); + + if (!validate(jobConfigListOptions)) { + throw new Error( + `Invalid job configuration (${filePath}): ${JSON.stringify(validate.errors, null, 2)}`, + ); + } + + // parse each JobConfig + return jobConfigListOptions.jobs.reduce( + (acc, jobConfigOptions) => { + const jobConfig: JobConfig = this.parseJobConfig({ + configVersion: jobConfigListOptions.configVersion, + ...jobConfigOptions, + }); + if (jobConfig.jobType in acc) { + throw new Error( + `Duplicate job type ${jobConfig.jobType} in ${filePath}`, + ); + } + acc[jobConfig.jobType] = jobConfig; + return acc; + }, + {} as Record, + ); + } + + private parseJobConfig(options: JobConfigOptions): JobConfig { + if (options.configVersion === undefined) { + throw new Error( + `No configVersion set for job type ${options.jobType} in ${this.filePath}`, + ); + } + return { + jobType: options.jobType, + configVersion: options.configVersion, + create: this.parseJobOperation( + options.create, + this.create_factories, + ), + statusUpdate: this.parseJobOperation( + options.statusUpdate, + this.update_factories, + ), + }; + } + + private parseJobOperation( + options: JobOperationOptions, + factories: Record>, + ): JobOperation { + const actionOptions = options.actions || []; + const actions = actionOptions.map((opt) => { + if (!(opt.actionType in factories)) { + throw new Error( + `Unknown action type ${opt.actionType} in ${this.filePath}`, + ); + } + return factories[opt.actionType].create(opt); + }); + return { + auth: options.auth, + actions: actions, + }; + } +} diff --git a/src/config/job-config/jobconfig.spec.ts b/src/config/job-config/jobconfig.spec.ts new file mode 100644 index 000000000..e7afb8bc4 --- /dev/null +++ b/src/config/job-config/jobconfig.spec.ts @@ -0,0 +1,55 @@ +import { ConfigModule } from "@nestjs/config"; +import { LogJobAction } from "./actions/logaction/logaction"; +import { JobConfigService } from "./jobconfig.service"; +import { Test } from "@nestjs/testing"; +import { actionType as logActionType } from "./actions/logaction/logaction.interface"; +import { actionType as validateActionType } from "./actions/validateaction/validateaction.interface"; +import { + CREATE_JOB_ACTION_FACTORIES, + UPDATE_JOB_ACTION_FACTORIES, +} from "./jobconfig.interface"; + +const actionFactoryMock = { + create: jest.fn(() => new LogJobAction({ actionType: "log" })), +}; +const factoriesMock = { + [logActionType]: actionFactoryMock, + [validateActionType]: actionFactoryMock, +}; +const factoryProviders = [ + { + provide: CREATE_JOB_ACTION_FACTORIES, + useValue: factoriesMock, + }, + { + provide: UPDATE_JOB_ACTION_FACTORIES, + useValue: factoriesMock, + }, +]; + +describe("Job configuration", () => { + const path = "test/config/jobconfig.yaml"; + + it("JobConfigService should be loaded from yaml", async () => { + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [() => ({ jobConfigurationFile: path })], + }), + ], + providers: [...factoryProviders, JobConfigService], + }).compile(); + + const jobConfigService = module.get(JobConfigService); + const jobConfigs = jobConfigService.allJobConfigs; + + expect("all_access" in jobConfigs); + const jobConfig = jobConfigService.get("all_access"); + expect(jobConfig.create.actions.length).toBe(1); + const action = jobConfig.create.actions[0]; + expect(action instanceof LogJobAction); + expect(action.getActionType()).toBe("log"); + + expect("validate" in jobConfigs); + }); +}); diff --git a/src/datasets/datasets.controller.spec.ts b/src/datasets/datasets.controller.spec.ts index 1a683dd6b..d0cee7220 100644 --- a/src/datasets/datasets.controller.spec.ts +++ b/src/datasets/datasets.controller.spec.ts @@ -1,21 +1,18 @@ import { Test, TestingModule } from "@nestjs/testing"; import { AttachmentsService } from "src/attachments/attachments.service"; -import { CaslModule } from "src/casl/casl.module"; import { DatablocksService } from "src/datablocks/datablocks.service"; import { OrigDatablocksService } from "src/origdatablocks/origdatablocks.service"; import { DatasetsController } from "./datasets.controller"; import { DatasetsService } from "./datasets.service"; import { LogbooksService } from "src/logbooks/logbooks.service"; +import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; class AttachmentsServiceMock {} - class DatablocksServiceMock {} - class DatasetsServiceMock {} - class OrigDatablocksServiceMock {} - class LogbooksServiceMock {} +class CaslAbilityFactoryMock {} describe("DatasetsController", () => { let controller: DatasetsController; @@ -23,13 +20,13 @@ describe("DatasetsController", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [DatasetsController], - imports: [CaslModule], providers: [ { provide: AttachmentsService, useClass: AttachmentsServiceMock }, { provide: LogbooksService, useClass: LogbooksServiceMock }, { provide: DatablocksService, useClass: DatablocksServiceMock }, { provide: DatasetsService, useClass: DatasetsServiceMock }, { provide: OrigDatablocksService, useClass: OrigDatablocksServiceMock }, + { provide: CaslAbilityFactory, useClass: CaslAbilityFactoryMock }, ], }).compile(); diff --git a/src/datasets/datasets.module.ts b/src/datasets/datasets.module.ts index 2f5758761..c426de443 100644 --- a/src/datasets/datasets.module.ts +++ b/src/datasets/datasets.module.ts @@ -3,7 +3,6 @@ import { MongooseModule } from "@nestjs/mongoose"; import { DatasetClass, DatasetSchema } from "./schemas/dataset.schema"; import { DatasetsController } from "./datasets.controller"; import { DatasetsService } from "./datasets.service"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { AttachmentsModule } from "src/attachments/attachments.module"; import { ConfigModule } from "@nestjs/config"; import { OrigDatablocksModule } from "src/origdatablocks/origdatablocks.module"; @@ -13,11 +12,13 @@ import { LogbooksModule } from "src/logbooks/logbooks.module"; import { PoliciesService } from "src/policies/policies.service"; import { PoliciesModule } from "src/policies/policies.module"; import { ElasticSearchModule } from "src/elastic-search/elastic-search.module"; +import { CaslModule } from "src/casl/casl.module"; @Module({ imports: [ AttachmentsModule, ConfigModule, + CaslModule, DatablocksModule, OrigDatablocksModule, InitialDatasetsModule, @@ -65,6 +66,6 @@ import { ElasticSearchModule } from "src/elastic-search/elastic-search.module"; ], exports: [DatasetsService], controllers: [DatasetsController], - providers: [DatasetsService, CaslAbilityFactory], + providers: [DatasetsService], }) export class DatasetsModule {} diff --git a/src/elastic-search/elastic-search.module.ts b/src/elastic-search/elastic-search.module.ts index 76927b28e..de85042a5 100644 --- a/src/elastic-search/elastic-search.module.ts +++ b/src/elastic-search/elastic-search.module.ts @@ -1,20 +1,15 @@ import { Module, forwardRef } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { DatasetsModule } from "src/datasets/datasets.module"; import { ElasticSearchServiceController } from "./elastic-search.controller"; import { ElasticSearchService } from "./elastic-search.service"; import { SearchQueryService } from "./providers/query-builder.service"; +import { CaslModule } from "src/casl/casl.module"; @Module({ - imports: [forwardRef(() => DatasetsModule), ConfigModule], + imports: [forwardRef(() => DatasetsModule), ConfigModule, CaslModule], controllers: [ElasticSearchServiceController], - providers: [ - ElasticSearchService, - SearchQueryService, - ConfigService, - CaslAbilityFactory, - ], + providers: [ElasticSearchService, SearchQueryService, ConfigService], exports: [ElasticSearchService, SearchQueryService], }) export class ElasticSearchModule {} diff --git a/src/instruments/instruments.controller.spec.ts b/src/instruments/instruments.controller.spec.ts index dac0c62f8..81c118535 100644 --- a/src/instruments/instruments.controller.spec.ts +++ b/src/instruments/instruments.controller.spec.ts @@ -4,6 +4,7 @@ import { InstrumentsController } from "./instruments.controller"; import { InstrumentsService } from "./instruments.service"; class InstrumentsServiceMock {} +class CaslAbilityFactoryMock {} describe("InstrumentsController", () => { let controller: InstrumentsController; @@ -12,8 +13,8 @@ describe("InstrumentsController", () => { const module: TestingModule = await Test.createTestingModule({ controllers: [InstrumentsController], providers: [ - CaslAbilityFactory, { provide: InstrumentsService, useClass: InstrumentsServiceMock }, + { provide: CaslAbilityFactory, useClass: CaslAbilityFactoryMock }, ], }).compile(); diff --git a/src/instruments/instruments.module.ts b/src/instruments/instruments.module.ts index 802ed0d95..bf713f0aa 100644 --- a/src/instruments/instruments.module.ts +++ b/src/instruments/instruments.module.ts @@ -3,11 +3,12 @@ import { InstrumentsService } from "./instruments.service"; import { InstrumentsController } from "./instruments.controller"; import { MongooseModule } from "@nestjs/mongoose"; import { Instrument, InstrumentSchema } from "./schemas/instrument.schema"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; +import { CaslModule } from "src/casl/casl.module"; @Module({ controllers: [InstrumentsController], imports: [ + CaslModule, MongooseModule.forFeatureAsync([ { name: Instrument.name, @@ -27,6 +28,6 @@ import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; }, ]), ], - providers: [InstrumentsService, CaslAbilityFactory], + providers: [InstrumentsService], }) export class InstrumentsModule {} diff --git a/src/jobs/actions/emailaction.ts b/src/jobs/actions/emailaction.ts deleted file mode 100644 index dd17a0478..000000000 --- a/src/jobs/actions/emailaction.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Send emails in response to job events - * This is intended as an example of the JobAction interface - * - */ -import { readFileSync } from "fs"; -import { compile, TemplateDelegate } from "handlebars"; -import { createTransport, Transporter } from "nodemailer"; -import { Logger, NotFoundException } from "@nestjs/common"; -import { JobAction, JobDto } from "../config/jobconfig"; -import { JobClass } from "../schemas/job.schema"; - -// Handlebar options for JobClass templates -const jobTemplateOptions = { - allowedProtoProperties: { - id: true, - type: true, - statusCode: true, - statusMessage: true, - createdBy: true, - jobParams: true, - contactEmail: true, - }, - allowProtoPropertiesByDefault: false, // limit accessible fields for security -}; - -type MailOptions = { - to: string; - from: string; - subject: string; - text?: string; -}; - -type Auth = { - user: string; - password: string; -}; - -/** - * Send an email following a job - */ -export class EmailJobAction implements JobAction { - public static readonly actionType = "email"; - - private mailService: Transporter; - private toTemplate: TemplateDelegate; - private from: string; - private auth: Auth | object = {}; - private subjectTemplate: TemplateDelegate; - private bodyTemplate: TemplateDelegate; - - getActionType(): string { - return EmailJobAction.actionType; - } - - constructor(data: Record) { - Logger.log( - "Initializing EmailJobAction. Params: " + JSON.stringify(data), - "EmailJobAction", - ); - - if (data["auth"]) { - // check optional auth field - function CheckAuthDefinition(obj: object): obj is Auth { - return ( - Object.keys(obj).length == 2 && "user" in obj && "password" in obj - ); - } - - if (!CheckAuthDefinition(data["auth"])) { - throw new NotFoundException( - "Param 'auth' should contain fields 'user' and 'password' only.", - ); - } - this.auth = data["auth"] as Auth; - } - if (!data["to"]) { - throw new NotFoundException("Param 'to' is undefined"); - } - if (!data["from"]) { - throw new NotFoundException("Param 'from' is undefined"); - } - if (!data["subject"]) { - throw new NotFoundException("Param 'subject' is undefined"); - } - if (!data["bodyTemplateFile"]) { - throw new NotFoundException("Param 'bodyTemplateFile' is undefined"); - } - Logger.log("EmailJobAction parameters are valid.", "EmailJobAction"); - - // const mailerConfig = configuration().smtp; - // this.mailService = createTransport({ - // host: mailerConfig.host, - // port: mailerConfig.port, - // secure: mailerConfig.secure, - // auth: this.auth - // } as any); - - this.from = data["from"] as string; - this.toTemplate = compile(data["to"]); - this.subjectTemplate = compile(data["subject"]); - - const templateFile = readFileSync( - data["bodyTemplateFile"] as string, - "utf8", - ); - this.bodyTemplate = compile(templateFile); - } - - async performJob(job: JobClass) { - Logger.log( - "Performing EmailJobAction: " + JSON.stringify(job), - "EmailJobAction", - ); - - // Fill templates - const mail: MailOptions = { - to: this.toTemplate(job, jobTemplateOptions), - from: this.from, - subject: this.subjectTemplate(job, jobTemplateOptions), - }; - mail.text = this.bodyTemplate(job, jobTemplateOptions); - Logger.log(mail); - - // Send the email - // await this.mailService.sendMail(mail); - } -} diff --git a/src/jobs/actions/logaction.ts b/src/jobs/actions/logaction.ts deleted file mode 100644 index f3199e9d6..000000000 --- a/src/jobs/actions/logaction.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Simple JobAction for logging events. - * This is intended as an example of the JobAction interface - * - */ -import { Logger } from "@nestjs/common"; -import { JobAction, JobDto } from "../config/jobconfig"; -import { JobClass } from "../schemas/job.schema"; - -export class LogJobAction implements JobAction { - public static readonly actionType = "log"; - - getActionType(): string { - return LogJobAction.actionType; - } - - async validate(dto: T) { - Logger.log("Validating job dto: " + JSON.stringify(dto), "LogJobAction"); - } - - async performJob(job: JobClass) { - Logger.log("Performing job: " + JSON.stringify(job), "LogJobAction"); - } - - constructor(data: Record) { - Logger.log( - "Initializing LogJobAction. Params: " + JSON.stringify(data), - "LogJobAction", - ); - } -} diff --git a/src/jobs/actions/urlaction.ts b/src/jobs/actions/urlaction.ts deleted file mode 100644 index 5c4db27c4..000000000 --- a/src/jobs/actions/urlaction.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Logger, NotFoundException, HttpException } from "@nestjs/common"; -import { JobAction, JobDto } from "../config/jobconfig"; -import { JobClass } from "../schemas/job.schema"; -import * as Handlebars from "handlebars"; - -// Handlebar options for JobClass templates -// TODO should this be moved into job.schema.ts? -const jobTemplateOptions = { - allowedProtoProperties: { - id: true, - type: true, - statusCode: true, - statusMessage: true, - messageSent: true, - createdBy: true, - jobParams: false, - }, - allowProtoPropertiesByDefault: false, // limit accessible fields for security -}; - -/** - * Type guard for Record - * @param obj - * @returns - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function isStringRecord(obj: any): obj is Record { - return ( - typeof obj === "object" && - obj !== null && - Object.keys(obj).every( - (key) => typeof key === "string" && typeof obj[key] === "string", - ) - ); -} - -/** - * Respond to Job events by making an HTTP call. - */ -export class URLAction implements JobAction { - public static readonly actionType = "url"; - - private urlTemplate: Handlebars.TemplateDelegate; - private method = "GET"; - private headerTemplates?: Record< - string, - Handlebars.TemplateDelegate - > = {}; - private bodyTemplate?: Handlebars.TemplateDelegate; - - getActionType(): string { - return URLAction.actionType; - } - - async performJob(job: JobClass) { - const url = encodeURI(this.urlTemplate(job, jobTemplateOptions)); - Logger.log(`Requesting ${url}`, "URLAction"); - - const response = await fetch(url, { - method: this.method, - headers: this.headerTemplates - ? Object.fromEntries( - Object.entries(this.headerTemplates).map(([key, template]) => [ - key, - template(job, jobTemplateOptions), - ]), - ) - : undefined, - body: this.bodyTemplate - ? this.bodyTemplate(job, jobTemplateOptions) - : undefined, - }); - - Logger.log(`Request for ${url} returned ${response.status}`, "URLAction"); - if (!response.ok) { - throw new HttpException( - { - status: response.status, - message: `Got response: ${await response.text()}`, - }, - response.status, - ); - } - - // TODO do something with the response? - } - - /** - * Constructor for the class. - * - * @param {Record} data - The data object should contain the following properties: - * - url (required): the URL for the request - * - method (optional): the HTTP method for the request, e.g. "GET", "POST" - * - headers (optional): an object containing HTTP headers to be included in the request - * - body (optional): the body of the request, for methods like "POST" or "PUT" - * - * @throws {NotFoundException} If the 'url' parameter is not provided in the data object - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(data: Record) { - if (!data["url"]) { - throw new NotFoundException("Param 'url' is undefined in url action"); - } - this.urlTemplate = Handlebars.compile(data.url); - - if (data["method"]) { - this.method = data.method; - } - - if (data["headers"]) { - if (!isStringRecord(data.headers)) { - throw new NotFoundException( - "Param 'headers' should map strings to strings", - ); - } - this.headerTemplates = Object.fromEntries( - Object.entries(data.headers).map(([key, value]) => [ - key, - Handlebars.compile(value), - ]), - ); - } - - if (data["body"]) { - this.bodyTemplate = Handlebars.compile(data["body"]); - } - } -} diff --git a/src/jobs/config/jobconfig.spec.ts b/src/jobs/config/jobconfig.spec.ts deleted file mode 100644 index 20b49f240..000000000 --- a/src/jobs/config/jobconfig.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { loadJobConfig, getRegisteredCreateActions } from "./jobconfig"; -import { LogJobAction } from "../actions/logaction"; -import { registerDefaultActions } from "../../config/configuration"; - -describe("Job configuration", () => { - // TODO should be done automatically on init? - registerDefaultActions(); - - it("LogJobAction should be registered", async () => { - const actions = getRegisteredCreateActions(); - expect("log" in actions); - }); - it("Should be able to load from yaml", async () => { - const path = "test/config/jobconfig.yaml"; - const config = await loadJobConfig(path); - expect(config).toBeDefined(); - expect(config.length).toBe(11); - expect(config[0].jobType).toBe("all_access"); - expect(config[0].create).toBeDefined(); - const create = config[0].create; - expect(create.actions.length).toBe(1); - const action = create.actions[0]; - expect(action instanceof LogJobAction); - expect(action.getActionType()).toBe("log"); - }); -}); diff --git a/src/jobs/config/jobconfig.ts b/src/jobs/config/jobconfig.ts deleted file mode 100644 index 0a279edb2..000000000 --- a/src/jobs/config/jobconfig.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Job configuration - * - * Upon startup, first modules and plugins implementing JobAction should register - * themselves with the various `register*Action` methods. - * - * Calling `configuration()` from configuration.ts causes the JobConfig to be - * instantiated for each job type (archive, retrieve, etc). The actions for this - * JobConfig are also instantiated by calling `parse` on the registered action with - * matching action type. This is passed the JSON configuration object. - * - * Upon receiving an API request to create, read, or update a job, all configured - * actions for that job/action combination are called to first verify the request body - * and then perform the appropriate action. - */ -import * as fs from "fs"; -import { JobClass } from "../schemas/job.schema"; -import { CreateJobDto } from "../dto/create-job.dto"; -import { StatusUpdateJobDto } from "../dto/status-update-job.dto"; -import { JobsConfigSchema } from "../types/jobs-config-schema.enum"; -import { Action } from "src/casl/action.enum"; -import { CreateJobAuth, JobsAuth } from "../types/jobs-auth.enum"; -import Ajv from "ajv"; -import { JobConfigSchema } from "./jobConfig.schema"; -import { load } from "js-yaml"; - -export type JobDto = CreateJobDto | StatusUpdateJobDto; - -/** - * Encapsulates all responses to a particular job type (eg "archive") - */ -export class JobConfig { - jobType: string; - configVersion: string; - create: JobOperation; - statusUpdate: JobOperation; - - constructor( - jobType: string, - configVersion: string, - create: JobOperation, - statusUpdate: JobOperation, - ) { - this.jobType = jobType; - this.configVersion = configVersion; - this.create = create; - this.statusUpdate = statusUpdate; - } - - /** - * Parse job configuration json by dispatching to currently registered JobActions - * @param data JSON - * @returns - */ - - static parse( - jobData: Record, - configVersion: string, - ): JobConfig { - if ( - !(JobsConfigSchema.JobType in jobData) || - typeof jobData[JobsConfigSchema.JobType] !== "string" - ) { - throw new Error(`Invalid job type`); - } - const type = jobData[JobsConfigSchema.JobType] as string; - if (!(Action.Create in jobData)) { - throw new Error(`No ${Action.Create} configured for job type "${type}"`); - } - if (!(Action.StatusUpdate in jobData)) { - throw new Error( - `No ${Action.StatusUpdate} configured for job type "${type}"`, - ); - } - const create = JobOperation.parse( - createActions, - jobData[Action.Create] as Record, - ); - const statusUpdate = JobOperation.parse( - statusUpdateActions, - jobData[Action.StatusUpdate] as Record, - ); - return new JobConfig(type, configVersion, create, statusUpdate); - } -} - -/** - * Encapsulates all information for a particular job operation (eg "create", "statusUpdate") - */ -export class JobOperation { - auth: JobsAuth | undefined; - actions: JobAction[]; - - constructor(actions: JobAction[] = [], auth: JobsAuth | undefined) { - this.actions = actions; - this.auth = auth; - } - - static parse( - actionList: Record>, - data: Record, - ): JobOperation { - // if Auth is not defined, default to #authenticated - let auth: JobsAuth = CreateJobAuth.Authenticated; - if (data[JobsConfigSchema.Auth]) { - // don't bother to validate auth value - if (typeof data[JobsConfigSchema.Auth] !== "string") { - throw new Error( - `Invalid auth value "${data[JobsConfigSchema.Auth]}" for job type`, - ); - } - auth = data[JobsConfigSchema.Auth] as JobsAuth; - } - let actionsData: unknown[] = []; - if (JobsConfigSchema.Actions in data) { - if (!Array.isArray(data[JobsConfigSchema.Actions])) { - throw new Error(`Expected array for ${JobsConfigSchema.Actions} value`); - } - actionsData = data[JobsConfigSchema.Actions]; - } - const actions = actionsData.map((json) => { - if (typeof json !== "object") { - throw new Error(`Expected object for job config action`); - } - return parseAction(actionList, json as Record); - }); - return new JobOperation(actions, auth); - } -} - -/** - * Given a JSON object configuring a JobConfigAction. - * - * This is dispatched to registered constructors (see registerCreateAction) based on - * the "actionType" field of data. Other parameters are action-specific. - * @param data JSON configuration data - * @returns - */ -function parseAction( - actionList: Record>, - data: Record, -): JobAction { - if (!(JobsConfigSchema.ActionType in data)) - throw SyntaxError(`No action.actionType in ${JSON.stringify(data)}`); - if (typeof data[JobsConfigSchema.ActionType] !== "string") { - throw SyntaxError(`Expected string for ${JobsConfigSchema.ActionType}`); - } - const type = data[JobsConfigSchema.ActionType]; - if (!(type in actionList)) { - throw SyntaxError(`No handler found for actions of type ${type}`); - } - - const actionClass = actionList[type]; - return new actionClass(data); -} - -/** - * Superclass for all responses to Job changes - */ -export interface JobAction { - /** - * Validate that the request body for this job operation. - * - * Note that the configuration of this action is validated in the constructor. - * Actions that don't need custom DTO methods can omit this method. - * - * @param dto data transfer object received from the client - * @throw HttpException if the DTO is invalid - * @returns - */ - validate?: (dto: DtoType) => Promise; - - /** - * Respond to the action - */ - performJob: (job: JobClass) => Promise; - - /** - * Return the actionType for this action. This should match the class's - * static actionType (used for constructing the class from the configuration file) - */ - getActionType(): string; -} - -/** - * Describes the constructor and static members for JobAction implementations - */ -export interface JobActionClass { - /** - * Action type, eg "url". Matched during parsing of the action - */ - readonly actionType: string; - new (json: Record): JobAction; -} - -export type JobCreateAction = JobAction; -// export type JobReadAction = JobAction; -export type JobStatusUpdateAction = JobAction; - -/** - * Action registration - */ -const createActions: Record> = {}; -const statusUpdateActions: Record< - string, - JobActionClass -> = {}; - -/** - * Registers an action to handle jobs of a particular type - * @param action - */ -export function registerCreateAction(action: JobActionClass) { - createActions[action.actionType] = action; -} - -export function registerStatusUpdateAction( - action: JobActionClass, -) { - statusUpdateActions[action.actionType] = action; -} - -/** - * List of action types with a registered action - * @returns - */ -export function getRegisteredCreateActions(): string[] { - return Object.keys(createActions); -} - -export function getRegisteredStatusUpdateActions(): string[] { - return Object.keys(statusUpdateActions); -} - -/** - * Parsing - */ -let jobConfig: JobConfig[] | null = null; // singleton - -/** - * Load jobconfig.yaml (or json) file. - * Expects one or more JobConfig configurations (see JobConfig.parse) - * @param filePath path to json config file - * @returns - */ -export function loadJobConfig(filePath: string): JobConfig[] { - if (jobConfig !== null) { - return jobConfig; - } - - const yaml = fs.readFileSync(filePath, "utf8"); - const data = load(yaml, { filename: filePath }); - - // Validate schema - type JobConfigWrapper = { - configVersion: string; - jobs: Record[]; - }; - const ajv = new Ajv(); - const validate = ajv.compile(JobConfigSchema); - - if (!validate(data)) { - throw new Error( - `Invalid job configuration (${filePath}): ${JSON.stringify(validate.errors, null, 2)}`, - ); - } - - jobConfig = data.jobs.map((jobData) => - JobConfig.parse(jobData, data.configVersion), - ); - return jobConfig as JobConfig[]; -} diff --git a/src/jobs/jobs.controller.spec.ts b/src/jobs/jobs.controller.spec.ts index 73d0358e3..5695b7405 100644 --- a/src/jobs/jobs.controller.spec.ts +++ b/src/jobs/jobs.controller.spec.ts @@ -1,34 +1,49 @@ import { EventEmitter2 } from "@nestjs/event-emitter"; import { Test, TestingModule } from "@nestjs/testing"; import { CaslModule } from "src/casl/casl.module"; -//import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { DatasetsService } from "src/datasets/datasets.service"; import { OrigDatablocksService } from "src/origdatablocks/origdatablocks.service"; import { JobsController } from "./jobs.controller"; import { JobsService } from "./jobs.service"; import { UsersService } from "src/users/users.service"; +import { MailerModule, MailerService } from "@nestjs-modules/mailer"; + +import { JobConfigService } from "src/config/job-config/jobconfig.service"; +import { ConfigModule } from "@nestjs/config"; class JobsServiceMock {} class DatasetsServiceMock {} class OrigDatablocksServiceMock {} class UsersServiceMock {} +class MailerServiceMock {} +class JobsConfigMock {} describe("JobsController", () => { let controller: JobsController; beforeEach(async () => { + const path = "test/config/jobconfig.yaml"; const module: TestingModule = await Test.createTestingModule({ controllers: [JobsController], - imports: [CaslModule], + imports: [ + ConfigModule.forRoot({ + load: [() => ({ jobConfigurationFile: path })], + }), + MailerModule.forRoot(), + CaslModule, + ], providers: [ - //CaslAbilityFactory, + { provide: JobConfigService, useClass: JobsConfigMock }, { provide: JobsService, useClass: JobsServiceMock }, { provide: DatasetsService, useClass: DatasetsServiceMock }, { provide: OrigDatablocksService, useClass: OrigDatablocksServiceMock }, { provide: UsersService, useClass: UsersServiceMock }, { provide: EventEmitter2, useClass: EventEmitter2 }, ], - }).compile(); + }) + .overrideProvider(MailerService) + .useClass(MailerServiceMock) + .compile(); controller = module.get(JobsController); }); diff --git a/src/jobs/jobs.controller.ts b/src/jobs/jobs.controller.ts index 89beebf69..f0d8f6380 100644 --- a/src/jobs/jobs.controller.ts +++ b/src/jobs/jobs.controller.ts @@ -36,7 +36,6 @@ import { import { IFacets, IFilters } from "src/common/interfaces/common.interface"; import { DatasetsService } from "src/datasets/datasets.service"; import { JobsConfigSchema } from "./types/jobs-config-schema.enum"; -import configuration from "src/config/configuration"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { OrigDatablocksService } from "src/origdatablocks/origdatablocks.service"; import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; @@ -49,11 +48,18 @@ import { fullQueryExampleLimits, jobsFullQueryExampleFields, jobsFullQueryDescriptionFields, + parseBoolean, } from "src/common/utils"; -import { JobAction, JobDto, JobConfig } from "./config/jobconfig"; +import { + JobAction, + JobDto, + JobConfig, +} from "../config/job-config/jobconfig.interface"; import { JobType, DatasetState, JobParams } from "./types/job-types.enum"; import { IJobFields } from "./interfaces/job-filters.interface"; import { OrigDatablock } from "src/origdatablocks/schemas/origdatablock.schema"; +import { ConfigService } from "@nestjs/config"; +import { JobConfigService } from "../config/job-config/jobconfig.service"; @ApiBearerAuth() @ApiTags("jobs") @@ -68,6 +74,8 @@ export class JobsController { private caslAbilityFactory: CaslAbilityFactory, private readonly usersService: UsersService, private eventEmitter: EventEmitter2, + private configService: ConfigService, + private jobConfigService: JobConfigService, ) { this.jobDatasetAuthorization = Object.values(CreateJobAuth).filter((v) => v.includes("#dataset"), @@ -75,7 +83,7 @@ export class JobsController { } publishJob() { - if (configuration().rabbitMq.enabled) { + if (parseBoolean(this.configService.get("rabbitMq.enabled"))) { // TODO: This should publish the job to the message broker. // job.publishJob(ctx.instance, "jobqueue"); console.log("Saved Job %s#%s and published to message broker"); @@ -359,17 +367,8 @@ export class JobsController { * Check job type matching configuration */ getJobTypeConfiguration = (jobType: string) => { - const jobConfigs = configuration().jobConfiguration; - const matchingConfig = jobConfigs.filter((j) => j.jobType == jobType); - - if (matchingConfig.length != 1) { - if (matchingConfig.length > 1) { - Logger.error( - "More than one job configurations matching type " + jobType, - ); - } else { - Logger.error("No job configuration matching type " + jobType); - } + const jobConfig = this.jobConfigService.get(jobType); + if (!jobConfig) { // return error that job type does not exists throw new HttpException( { @@ -379,7 +378,7 @@ export class JobsController { HttpStatus.BAD_REQUEST, ); } - return matchingConfig[0]; + return jobConfig; }; /** @@ -419,9 +418,8 @@ export class JobsController { } if (user) { // the request comes from a user who is logged in. - if ( - user.currentGroups.some((g) => configuration().adminGroups.includes(g)) - ) { + const adminGroups = this.configService.get("adminGroups") || []; + if (user.currentGroups.some((g) => adminGroups.includes(g))) { // admin users let jobUser: JWTUser | null = user; if (user.username != jobCreateDto.ownerUser) { diff --git a/src/jobs/jobs.module.ts b/src/jobs/jobs.module.ts index 51cba0cec..da8b50751 100644 --- a/src/jobs/jobs.module.ts +++ b/src/jobs/jobs.module.ts @@ -3,19 +3,26 @@ import { JobsService } from "./jobs.service"; import { JobsController } from "./jobs.controller"; import { MongooseModule } from "@nestjs/mongoose"; import { JobClass, JobSchema } from "./schemas/job.schema"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { DatasetsModule } from "src/datasets/datasets.module"; import { PoliciesModule } from "src/policies/policies.module"; -import { CommonModule } from "src/common/common.module"; import { ConfigModule } from "@nestjs/config"; import { OrigDatablocksModule } from "src/origdatablocks/origdatablocks.module"; import { UsersModule } from "src/users/users.module"; +import { JobConfigModule } from "../config/job-config/jobconfig.module"; +import { JobConfigService } from "../config/job-config/jobconfig.service"; +import { DefaultJobActionFactories } from "../config/job-config/actions/defaultjobactions.module"; +import { EmailJobActionFactory } from "src/config/job-config/actions/emailaction/emailaction.factory"; +import { CommonModule } from "src/common/common.module"; +import { CaslModule } from "src/casl/casl.module"; @Module({ controllers: [JobsController], imports: [ CommonModule, + CaslModule, ConfigModule, + DefaultJobActionFactories, + JobConfigModule, DatasetsModule, UsersModule, MongooseModule.forFeatureAsync([ @@ -40,6 +47,6 @@ import { UsersModule } from "src/users/users.module"; PoliciesModule, OrigDatablocksModule, ], - providers: [JobsService, CaslAbilityFactory], + providers: [JobsService, JobConfigService, EmailJobActionFactory], }) export class JobsModule {} diff --git a/src/logbooks/logbooks.controller.spec.ts b/src/logbooks/logbooks.controller.spec.ts index 2e72dd30a..b9050e27e 100644 --- a/src/logbooks/logbooks.controller.spec.ts +++ b/src/logbooks/logbooks.controller.spec.ts @@ -1,12 +1,12 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { CaslModule } from "src/casl/casl.module"; import { ProposalsService } from "src/proposals/proposals.service"; import { LogbooksController } from "./logbooks.controller"; import { LogbooksService } from "./logbooks.service"; +import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; class LogbooksServiceMock {} - class ProposalsServiceMock {} +class CaslAbilityFactoryMock {} describe("LogbooksController", () => { let controller: LogbooksController; @@ -14,10 +14,10 @@ describe("LogbooksController", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [LogbooksController], - imports: [CaslModule], providers: [ { provide: LogbooksService, useClass: LogbooksServiceMock }, { provide: ProposalsService, useClass: ProposalsServiceMock }, + { provide: CaslAbilityFactory, useClass: CaslAbilityFactoryMock }, ], }).compile(); diff --git a/src/logbooks/logbooks.module.ts b/src/logbooks/logbooks.module.ts index 38e735fbd..3b002d707 100644 --- a/src/logbooks/logbooks.module.ts +++ b/src/logbooks/logbooks.module.ts @@ -3,12 +3,13 @@ import { LogbooksService } from "./logbooks.service"; import { LogbooksController } from "./logbooks.controller"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { HttpModule } from "@nestjs/axios"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { ProposalsModule } from "src/proposals/proposals.module"; +import { CaslModule } from "src/casl/casl.module"; @Module({ imports: [ ConfigModule, + CaslModule, HttpModule.registerAsync({ imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ @@ -21,6 +22,6 @@ import { ProposalsModule } from "src/proposals/proposals.module"; ], exports: [LogbooksService], controllers: [LogbooksController], - providers: [LogbooksService, CaslAbilityFactory], + providers: [LogbooksService], }) export class LogbooksModule {} diff --git a/src/origdatablocks/origdatablocks.module.ts b/src/origdatablocks/origdatablocks.module.ts index aaea0cfe4..7e2146c37 100644 --- a/src/origdatablocks/origdatablocks.module.ts +++ b/src/origdatablocks/origdatablocks.module.ts @@ -6,12 +6,13 @@ import { OrigDatablockSchema, } from "./schemas/origdatablock.schema"; import { OrigDatablocksController } from "./origdatablocks.controller"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { DatasetsModule } from "src/datasets/datasets.module"; +import { CaslModule } from "src/casl/casl.module"; @Module({ imports: [ forwardRef(() => DatasetsModule), + CaslModule, MongooseModule.forFeature([ { name: OrigDatablock.name, @@ -21,6 +22,6 @@ import { DatasetsModule } from "src/datasets/datasets.module"; ], controllers: [OrigDatablocksController], exports: [OrigDatablocksService], - providers: [OrigDatablocksService, CaslAbilityFactory], + providers: [OrigDatablocksService], }) export class OrigDatablocksModule {} diff --git a/src/policies/policies.controller.spec.ts b/src/policies/policies.controller.spec.ts index d8b261922..53b8261f4 100644 --- a/src/policies/policies.controller.spec.ts +++ b/src/policies/policies.controller.spec.ts @@ -1,12 +1,12 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { DatasetsService } from "src/datasets/datasets.service"; import { PoliciesController } from "./policies.controller"; import { PoliciesService } from "./policies.service"; +import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; class PoliciesServiceMock {} - class DatasetsServiceMock {} +class CaslAbilityFactoryMock {} describe("PoliciesController", () => { let controller: PoliciesController; @@ -15,9 +15,9 @@ describe("PoliciesController", () => { const module: TestingModule = await Test.createTestingModule({ controllers: [PoliciesController], providers: [ - CaslAbilityFactory, { provide: PoliciesService, useClass: PoliciesServiceMock }, { provide: DatasetsService, useClass: DatasetsServiceMock }, + { provide: CaslAbilityFactory, useClass: CaslAbilityFactoryMock }, ], }).compile(); diff --git a/src/policies/policies.module.ts b/src/policies/policies.module.ts index 0782391fe..f0cc4c4fb 100644 --- a/src/policies/policies.module.ts +++ b/src/policies/policies.module.ts @@ -7,13 +7,14 @@ import { Policy, PolicySchema } from "./schemas/policy.schema"; import { AuthModule } from "src/auth/auth.module"; import { UsersModule } from "src/users/users.module"; import { DatasetsModule } from "src/datasets/datasets.module"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; +import { CaslModule } from "src/casl/casl.module"; @Module({ controllers: [PoliciesController], imports: [ AuthModule, ConfigModule, + CaslModule, forwardRef(() => DatasetsModule), MongooseModule.forFeature([ { @@ -23,7 +24,7 @@ import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; ]), UsersModule, ], - providers: [PoliciesService, CaslAbilityFactory], + providers: [PoliciesService], exports: [PoliciesService], }) export class PoliciesModule {} diff --git a/src/proposals/dto/update-proposal.dto.ts b/src/proposals/dto/update-proposal.dto.ts index c16ef17cb..2e5fc2e06 100644 --- a/src/proposals/dto/update-proposal.dto.ts +++ b/src/proposals/dto/update-proposal.dto.ts @@ -4,7 +4,6 @@ import { IsArray, IsDateString, IsEmail, - IsEnum, IsObject, IsOptional, IsString, diff --git a/src/proposals/proposals.controller.spec.ts b/src/proposals/proposals.controller.spec.ts index 12b627d8f..532511b84 100644 --- a/src/proposals/proposals.controller.spec.ts +++ b/src/proposals/proposals.controller.spec.ts @@ -1,15 +1,14 @@ import { Test, TestingModule } from "@nestjs/testing"; import { AttachmentsService } from "src/attachments/attachments.service"; -import { CaslModule } from "src/casl/casl.module"; import { DatasetsService } from "src/datasets/datasets.service"; import { ProposalsController } from "./proposals.controller"; import { ProposalsService } from "./proposals.service"; +import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; class AttachmentsServiceMock {} - class DatasetsServiceMock {} - class ProposalsServiceMock {} +class CaslAbilityFactoryMock {} describe("ProposalsController", () => { let controller: ProposalsController; @@ -17,11 +16,11 @@ describe("ProposalsController", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ProposalsController], - imports: [CaslModule], providers: [ { provide: AttachmentsService, useClass: AttachmentsServiceMock }, { provide: DatasetsService, useClass: DatasetsServiceMock }, { provide: ProposalsService, useClass: ProposalsServiceMock }, + { provide: CaslAbilityFactory, useClass: CaslAbilityFactoryMock }, ], }).compile(); diff --git a/src/proposals/proposals.module.ts b/src/proposals/proposals.module.ts index c684c2e87..9e2e1f71c 100644 --- a/src/proposals/proposals.module.ts +++ b/src/proposals/proposals.module.ts @@ -3,15 +3,16 @@ import { ProposalsService } from "./proposals.service"; import { ProposalsController } from "./proposals.controller"; import { MongooseModule } from "@nestjs/mongoose"; import { ProposalClass, ProposalSchema } from "./schemas/proposal.schema"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { AttachmentsModule } from "src/attachments/attachments.module"; import { DatasetsModule } from "src/datasets/datasets.module"; import { ConfigModule, ConfigService } from "@nestjs/config"; +import { CaslModule } from "src/casl/casl.module"; @Module({ imports: [ AttachmentsModule, forwardRef(() => DatasetsModule), + CaslModule, MongooseModule.forFeatureAsync([ { name: ProposalClass.name, @@ -45,6 +46,6 @@ import { ConfigModule, ConfigService } from "@nestjs/config"; ], exports: [ProposalsService], controllers: [ProposalsController], - providers: [ProposalsService, CaslAbilityFactory], + providers: [ProposalsService], }) export class ProposalsModule {} diff --git a/src/published-data/published-data.controller.spec.ts b/src/published-data/published-data.controller.spec.ts index e2fd80b68..2064fd915 100644 --- a/src/published-data/published-data.controller.spec.ts +++ b/src/published-data/published-data.controller.spec.ts @@ -2,21 +2,18 @@ import { HttpService } from "@nestjs/axios"; import { ConfigService } from "@nestjs/config"; import { Test, TestingModule } from "@nestjs/testing"; import { AttachmentsService } from "src/attachments/attachments.service"; -import { CaslModule } from "src/casl/casl.module"; import { DatasetsService } from "src/datasets/datasets.service"; import { ProposalsService } from "src/proposals/proposals.service"; import { PublishedDataController } from "./published-data.controller"; import { PublishedDataService } from "./published-data.service"; +import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; class AttachmentsServiceMock {} - class DatasetsServiceMock {} - class HttpServiceMock {} - class ProposalsServiceMock {} - class PublishedDataServiceMock {} +class CaslAbilityFactoryMock {} describe("PublishedDataController", () => { let controller: PublishedDataController; @@ -24,7 +21,6 @@ describe("PublishedDataController", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [PublishedDataController], - imports: [CaslModule], providers: [ ConfigService, { provide: AttachmentsService, useClass: AttachmentsServiceMock }, @@ -32,6 +28,7 @@ describe("PublishedDataController", () => { { provide: HttpService, useClass: HttpServiceMock }, { provide: ProposalsService, useClass: ProposalsServiceMock }, { provide: PublishedDataService, useClass: PublishedDataServiceMock }, + { provide: CaslAbilityFactory, useClass: CaslAbilityFactoryMock }, ], }).compile(); diff --git a/src/published-data/published-data.module.ts b/src/published-data/published-data.module.ts index bfb289cfd..1a9bbd68d 100644 --- a/src/published-data/published-data.module.ts +++ b/src/published-data/published-data.module.ts @@ -6,17 +6,18 @@ import { PublishedData, PublishedDataSchema, } from "./schemas/published-data.schema"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { AttachmentsModule } from "src/attachments/attachments.module"; import { DatasetsModule } from "src/datasets/datasets.module"; import { ProposalsModule } from "src/proposals/proposals.module"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { HttpModule } from "@nestjs/axios"; +import { CaslModule } from "src/casl/casl.module"; @Module({ imports: [ AttachmentsModule, ConfigModule, + CaslModule, DatasetsModule, HttpModule.registerAsync({ imports: [ConfigModule], @@ -47,6 +48,6 @@ import { HttpModule } from "@nestjs/axios"; ProposalsModule, ], controllers: [PublishedDataController], - providers: [PublishedDataService, CaslAbilityFactory], + providers: [PublishedDataService], }) export class PublishedDataModule {} diff --git a/src/samples/samples.controller.spec.ts b/src/samples/samples.controller.spec.ts index c391cecc6..c71fe54e8 100644 --- a/src/samples/samples.controller.spec.ts +++ b/src/samples/samples.controller.spec.ts @@ -1,15 +1,14 @@ import { Test, TestingModule } from "@nestjs/testing"; import { AttachmentsService } from "src/attachments/attachments.service"; -import { CaslModule } from "src/casl/casl.module"; import { DatasetsService } from "src/datasets/datasets.service"; import { SamplesController } from "./samples.controller"; import { SamplesService } from "./samples.service"; +import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; class AttachmentsServiceMock {} - class DatasetsServiceMock {} - class SamplesServiceMock {} +class CaslAbilityFactoryMock {} describe("SamplesController", () => { let controller: SamplesController; @@ -17,11 +16,11 @@ describe("SamplesController", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [SamplesController], - imports: [CaslModule], providers: [ { provide: AttachmentsService, useClass: AttachmentsServiceMock }, { provide: DatasetsService, useClass: DatasetsServiceMock }, { provide: SamplesService, useClass: SamplesServiceMock }, + { provide: CaslAbilityFactory, useClass: CaslAbilityFactoryMock }, ], }).compile(); diff --git a/src/samples/samples.module.ts b/src/samples/samples.module.ts index bc0d64b30..206a348b8 100644 --- a/src/samples/samples.module.ts +++ b/src/samples/samples.module.ts @@ -5,13 +5,14 @@ import { AttachmentsModule } from "src/attachments/attachments.module"; import { DatasetsModule } from "src/datasets/datasets.module"; import { MongooseModule } from "@nestjs/mongoose"; import { SampleClass, SampleSchema } from "./schemas/sample.schema"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { ConfigModule } from "@nestjs/config"; +import { CaslModule } from "src/casl/casl.module"; @Module({ imports: [ AttachmentsModule, ConfigModule, + CaslModule, DatasetsModule, MongooseModule.forFeatureAsync([ { @@ -33,6 +34,6 @@ import { ConfigModule } from "@nestjs/config"; ]), ], controllers: [SamplesController], - providers: [SamplesService, CaslAbilityFactory], + providers: [SamplesService], }) export class SamplesModule {} diff --git a/src/users/user-identities.controller.spec.ts b/src/users/user-identities.controller.spec.ts index 61927b752..2174e3b60 100644 --- a/src/users/user-identities.controller.spec.ts +++ b/src/users/user-identities.controller.spec.ts @@ -1,9 +1,10 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { CaslModule } from "src/casl/casl.module"; import { UserIdentitiesController } from "./user-identities.controller"; import { UserIdentitiesService } from "./user-identities.service"; +import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; class UserIdentitiesServiceMock {} +class CaslAbilityFactoryMock {} describe("UserIdentitiesController", () => { let controller: UserIdentitiesController; @@ -11,9 +12,9 @@ describe("UserIdentitiesController", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [UserIdentitiesController], - imports: [CaslModule], providers: [ { provide: UserIdentitiesService, useClass: UserIdentitiesServiceMock }, + { provide: CaslAbilityFactory, useClass: CaslAbilityFactoryMock }, ], }).compile(); diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts index a1b58ea49..8fb6f23d3 100644 --- a/src/users/users.controller.spec.ts +++ b/src/users/users.controller.spec.ts @@ -1,11 +1,11 @@ import { Test, TestingModule } from "@nestjs/testing"; import { AuthService } from "src/auth/auth.service"; -import { CaslModule } from "src/casl/casl.module"; import { UsersController } from "./users.controller"; import { UsersService } from "./users.service"; import { UpdateUserSettingsDto } from "./dto/update-user-settings.dto"; import { Request } from "express"; import { UserSettings } from "./schemas/user-settings.schema"; +import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; class UsersServiceMock { findByIdUserIdentity(id: string) { @@ -37,6 +37,7 @@ const mockUserSettings = { }; class AuthServiceMock {} +class CaslAbilityFactoryMock {} describe("UsersController", () => { let controller: UsersController; @@ -45,10 +46,10 @@ describe("UsersController", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [UsersController], - imports: [CaslModule], providers: [ { provide: AuthService, useClass: AuthServiceMock }, { provide: UsersService, useClass: UsersServiceMock }, + { provide: CaslAbilityFactory, useClass: CaslAbilityFactoryMock }, ], }).compile(); diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 2ebc765b6..4ebd50bee 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -10,7 +10,6 @@ import { import { RolesService } from "./roles.service"; import { Role, RoleSchema } from "./schemas/role.schema"; import { UserRole, UserRoleSchema } from "./schemas/user-role.schema"; -import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { JwtModule } from "@nestjs/jwt"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { @@ -21,6 +20,7 @@ import { UserIdentitiesController } from "./user-identities.controller"; import { UserIdentitiesService } from "./user-identities.service"; import { AuthService } from "src/auth/auth.service"; import { accessGroupServiceFactory } from "src/auth/access-group-provider/access-group-service-factory"; +import { CaslModule } from "src/casl/casl.module"; @Module({ imports: [ @@ -33,6 +33,7 @@ import { accessGroupServiceFactory } from "src/auth/access-group-provider/access inject: [ConfigService], }), ConfigModule, + CaslModule, MongooseModule.forFeature([ { name: UserIdentity.name, @@ -58,7 +59,6 @@ import { accessGroupServiceFactory } from "src/auth/access-group-provider/access ], providers: [ AuthService, - CaslAbilityFactory, UsersService, UserIdentitiesService, RolesService,