From 55b4a41a7e511f57390cf46bfc4ca5edb7a5f55b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lenon?= Date: Sun, 25 Aug 2024 13:49:01 +0100 Subject: [PATCH] feat(cron): add cron application --- package-lock.json | 31 +++++- package.json | 9 +- src/applications/Cron.ts | 67 +++++++++++++ src/ignite/Ignite.ts | 21 +++- src/types/CronOptions.ts | 45 +++++++++ src/types/index.ts | 7 +- .../handlers/CustomCronExceptionHandler.ts | 15 +++ tests/fixtures/kernels/CustomCronKernel.ts | 15 +++ tests/fixtures/routes/cron.ts | 14 +++ tests/fixtures/schedulers/HelloScheduler.ts | 17 ++++ tests/unit/applications/CronTest.ts | 95 +++++++++++++++++++ 11 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 src/applications/Cron.ts create mode 100644 src/types/CronOptions.ts create mode 100644 tests/fixtures/handlers/CustomCronExceptionHandler.ts create mode 100644 tests/fixtures/kernels/CustomCronKernel.ts create mode 100644 tests/fixtures/routes/cron.ts create mode 100644 tests/fixtures/schedulers/HelloScheduler.ts create mode 100644 tests/unit/applications/CronTest.ts diff --git a/package-lock.json b/package-lock.json index 5216774..f240bb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/core", - "version": "4.45.0", + "version": "4.46.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/core", - "version": "4.45.0", + "version": "4.46.0", "license": "MIT", "dependencies": { "pretty-repl": "^3.1.2", @@ -16,6 +16,7 @@ "@athenna/artisan": "^4.45.0", "@athenna/common": "^4.46.0", "@athenna/config": "^4.27.0", + "@athenna/cron": "^4.1.0", "@athenna/http": "^4.41.0", "@athenna/ioc": "^4.27.0", "@athenna/logger": "^4.29.0", @@ -146,6 +147,19 @@ "node": ">=20.0.0" } }, + "node_modules/@athenna/cron": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@athenna/cron/-/cron-4.1.0.tgz", + "integrity": "sha512-k5g300Nm/1cXXqcSdcsihkB2iNjEL75xVZkiO7ShiU2dJmEITpo7Sb5gXLGxzxoXc9XvBRbp/Ffo+jLiXunvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-cron": "^3.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@athenna/http": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/@athenna/http/-/http-4.41.0.tgz", @@ -9858,6 +9872,19 @@ "tslib": "^2.0.3" } }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "dev": true, + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", diff --git a/package.json b/package.json index 286f69b..4fa10a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/core", - "version": "4.45.0", + "version": "4.46.0", "description": "One foundation for multiple applications.", "license": "MIT", "author": "João Lenon ", @@ -15,6 +15,9 @@ "rest-api", "http-server", "console", + "cron", + "cronjob", + "scheduler", "ignite", "nodejs", "athenna", @@ -80,6 +83,7 @@ "@athenna/artisan": "^4.45.0", "@athenna/common": "^4.46.0", "@athenna/config": "^4.27.0", + "@athenna/cron": "^4.1.0", "@athenna/http": "^4.41.0", "@athenna/ioc": "^4.27.0", "@athenna/logger": "^4.29.0", @@ -218,6 +222,9 @@ "directories": { "bootstrap": "bin" }, + "schedulers": [ + "#tests/fixtures/schedulers/HelloScheduler" + ], "commands": { "make:exception": { "path": "#src/commands/MakeExceptionCommand" diff --git a/src/applications/Cron.ts b/src/applications/Cron.ts new file mode 100644 index 0000000..8bdaf1b --- /dev/null +++ b/src/applications/Cron.ts @@ -0,0 +1,67 @@ +/** + * @athenna/core + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { debug } from '#src/debug' +import { Log } from '@athenna/logger' +import type { CronImpl } from '@athenna/cron' +import { Path, Module, Options } from '@athenna/common' +import type { CronOptions } from '#src/types/CronOptions' + +export class Cron { + /** + * Boot the Cron application. + */ + public static async boot(options?: CronOptions): Promise { + options = Options.create(options, { + trace: Config.get('cron.trace', false), + routePath: Path.routes(`cron.${Path.ext()}`), + kernelPath: '@athenna/cron/kernels/CronKernel' + }) + + const cron = ioc.safeUse('Athenna/Core/Cron') + + debug('booting cron application with options %o', options) + + await this.resolveKernel(options) + + if (Config.notExists('rc.bootLogs') || Config.is('rc.bootLogs', false)) { + return cron + } + + Log.channelOrVanilla('application').success( + `Cron application successfully started` + ) + + return cron + } + + /** + * Resolve the kernel by importing it and calling the methods to register + * schedulers, plugins and exception handler. + */ + private static async resolveKernel(options?: CronOptions) { + const Kernel = await Module.resolve( + options.kernelPath, + Config.get('rc.parentURL') + ) + + const kernel = new Kernel() + + await kernel.registerRTracer(options.trace) + await kernel.registerExceptionHandler(options.exceptionHandlerPath) + await kernel.registerSchedulers() + await kernel.registerRoutes(options.routePath) + + if (Config.is('rc.bootLogs', true)) { + Log.channelOrVanilla('application').success( + `Kernel ({yellow} ${Kernel.name}) successfully booted` + ) + } + } +} diff --git a/src/ignite/Ignite.ts b/src/ignite/Ignite.ts index 39abe4b..09ce116 100644 --- a/src/ignite/Ignite.ts +++ b/src/ignite/Ignite.ts @@ -8,13 +8,15 @@ */ import type { + SemverNode, + CronOptions, HttpOptions, - ConsoleOptions, IgniteOptions, - SemverNode + ConsoleOptions } from '#src/types' import { Ioc } from '@athenna/ioc' +import { Cron } from '#src/applications/Cron' import { Http } from '#src/applications/Http' import { EnvHelper, Rc } from '@athenna/config' import { isAbsolute, resolve } from 'node:path' @@ -147,6 +149,21 @@ export class Ignite { } } + /** + * Ignite the CRON application. + */ + public async cron(options?: CronOptions) { + try { + this.options.environments.push('cron') + + await this.fire() + + return await Cron.boot(options) + } catch (err) { + await this.handleError(err) + } + } + /** * Fire the application configuring the env variables file, configuration files * providers and preload files. diff --git a/src/types/CronOptions.ts b/src/types/CronOptions.ts new file mode 100644 index 0000000..7f888c2 --- /dev/null +++ b/src/types/CronOptions.ts @@ -0,0 +1,45 @@ +/** + * @athenna/core + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export type CronOptions = { + /** + * Create a custom trace uuid to all new requests done to the server using AsyncLocalStorage. + * + * @default Config.get('cron.trace', false) + */ + trace?: boolean + + /** + * The path to the cron routes. + * + * @default Path.routes(`cron.${Path.ext()}`) + */ + routePath?: string + + /** + * The path to the CronKernel. The cron kernel is responsible to register controllers, + * all kind of middlewares, plugins and the exception handler. By default, + * Athenna will use the built in Kernel. But you can do your own implementation + * extending the "CronKernel" class from Http and setting the path to it here. + * + * @default '@athenna/cron/kernels/CronKernel' + */ + kernelPath?: string + + /** + * The path to the exception handler of cron schedulers. The exception + * handler is responsible to handle all the exception that are throwed + * inside route handlers. By default, Athenna will use the built in exception + * handler. But you can do your own implementation extending the + * "CronExceptionHandler" class from Http and setting the path to it here. + * + * @default '@athenna/cron/handlers/CronExceptionHandler' + */ + exceptionHandlerPath?: string +} diff --git a/src/types/index.ts b/src/types/index.ts index bcf46aa..25c8b4c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,8 +7,9 @@ * file that was distributed with this source code. */ -export * from '#src/types/ConsoleOptions' -export * from '#src/types/HttpOptions' -export * from '#src/types/IgniteOptions' export * from '#src/types/RcOptions' export * from '#src/types/SemverNode' +export * from '#src/types/CronOptions' +export * from '#src/types/HttpOptions' +export * from '#src/types/IgniteOptions' +export * from '#src/types/ConsoleOptions' diff --git a/tests/fixtures/handlers/CustomCronExceptionHandler.ts b/tests/fixtures/handlers/CustomCronExceptionHandler.ts new file mode 100644 index 0000000..a78cad8 --- /dev/null +++ b/tests/fixtures/handlers/CustomCronExceptionHandler.ts @@ -0,0 +1,15 @@ +/** + * @athenna/core + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Log } from '@athenna/logger' +import { ConsoleExceptionHandler } from '@athenna/artisan' + +Log.info('importing CustomCronExceptionHandler') + +export class CustomCronExceptionHandler extends ConsoleExceptionHandler {} diff --git a/tests/fixtures/kernels/CustomCronKernel.ts b/tests/fixtures/kernels/CustomCronKernel.ts new file mode 100644 index 0000000..1d52580 --- /dev/null +++ b/tests/fixtures/kernels/CustomCronKernel.ts @@ -0,0 +1,15 @@ +/** + * @athenna/core + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Log } from '@athenna/logger' +import { CronKernel } from '@athenna/cron' + +Log.info('importing CustomCronKernel') + +export class CustomCronKernel extends CronKernel {} diff --git a/tests/fixtures/routes/cron.ts b/tests/fixtures/routes/cron.ts new file mode 100644 index 0000000..e450c50 --- /dev/null +++ b/tests/fixtures/routes/cron.ts @@ -0,0 +1,14 @@ +/** + * @athenna/core + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Cron } from '@athenna/cron' + +Cron.schedule() + .pattern('* * * * *') + .handler(() => {}) diff --git a/tests/fixtures/schedulers/HelloScheduler.ts b/tests/fixtures/schedulers/HelloScheduler.ts new file mode 100644 index 0000000..3d7d69f --- /dev/null +++ b/tests/fixtures/schedulers/HelloScheduler.ts @@ -0,0 +1,17 @@ +/** + * @athenna/core + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Scheduler, type Context } from '@athenna/cron' + +@Scheduler({ pattern: '* * * * *' }) +export class HelloScheduler { + public async handle(ctx: Context) { + console.log(ctx) + } +} diff --git a/tests/unit/applications/CronTest.ts b/tests/unit/applications/CronTest.ts new file mode 100644 index 0000000..8b07048 --- /dev/null +++ b/tests/unit/applications/CronTest.ts @@ -0,0 +1,95 @@ +/** + * @athenna/core + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Rc } from '@athenna/config' +import { Path } from '@athenna/common' +import { Cron } from '#src/applications/Cron' +import { CommanderHandler } from '@athenna/artisan' +import { Log, LoggerProvider } from '@athenna/logger' +import { Test, type Context, BeforeEach, AfterEach, Mock } from '@athenna/test' +import { Cron as CronFacade, CronProvider, CronKernel, CronBuilder } from '@athenna/cron' + +export default class CronTest { + private cronBuilder: CronBuilder + @BeforeEach() + public async beforeEach() { + new CronProvider().register() + + this.cronBuilder = new CronBuilder() + Mock.when(this.cronBuilder, 'handler').return(undefined) + CronFacade.when('schedule').return(this.cronBuilder) + + new LoggerProvider().register() + await Config.loadAll(Path.fixtures('config')) + await Rc.setFile(Path.fixtures('rcs/.athennarc.json')) + } + + @AfterEach() + public async afterEach() { + ioc.reconstruct() + Config.clear() + Mock.restoreAll() + CommanderHandler.reconstruct() + } + + @Test() + public async shouldBeAbleToBootACronApplication({ assert }: Context) { + await Cron.boot() + + assert.called(this.cronBuilder.handler) + } + + @Test() + public async shouldBeAbleToBootACronApplicationWithDifferentCronKernel({ assert }: Context) { + Log.when('info').return(undefined) + + await Cron.boot({ kernelPath: Path.fixtures('kernels/CustomCronKernel.ts') }) + + assert.calledOnceWith(Log.info, 'importing CustomCronKernel') + } + + @Test() + public async shouldBeAbleToBootACronApplicationWithDifferentExceptionHandler({ assert }: Context) { + Log.when('info').return(undefined) + + await Cron.boot({ exceptionHandlerPath: Path.fixtures('handlers/CustomCronExceptionHandler.ts') }) + + assert.calledOnceWith(Log.info, 'importing CustomCronExceptionHandler') + } + + @Test() + public async shouldBeAbleToBootACronApplicationWithTracingPluginRegistered({ assert }: Context) { + Mock.when(CronKernel.prototype, 'registerRTracer').resolve(undefined) + + await Cron.boot({ trace: true }) + + assert.calledOnceWith(CronKernel.prototype.registerRTracer, true) + } + + @Test() + public async shouldBeAbleToBootACronApplicationAndRegisterTheRouteFile({ assert }: Context) { + await Cron.boot({ routePath: Path.fixtures('routes/cron.ts') }) + + assert.called(this.cronBuilder.handler) + } + + @Test() + public async shouldBeAbleToBootACronApplicationAndLogTheBootstrapInfos({ assert }: Context) { + Config.set('rc.bootLogs', true) + const successMock = Mock.fake() + Log.when('channelOrVanilla').return({ + success: successMock + }) + + await Cron.boot() + + assert.calledWith(successMock, 'Cron application successfully started') + assert.calledWith(successMock, 'Kernel ({yellow} CronKernel) successfully booted') + } +}