UNPKG

@whook/whook

Version:

Build strong and efficient REST web services.

548 lines (503 loc) 18.2 kB
import { constant, name, location, autoService, singleton, alsoInject, type Injector, type Autoloader, type ProviderInitializer, type Initializer, type Dependencies, type Service, ServiceInitializer, } from 'knifecycle'; import { hasDefinedKey, noop } from '../libs/utils.js'; import initRoutesHandlers from './ROUTES_HANDLERS.js'; import initCronsHandlers from './CRONS_HANDLERS.js'; import initConsumersHandlers from './CRONS_HANDLERS.js'; import initTransformersHandlers from './TRANSFORMERS_HANDLERS.js'; import initCommandsHandlers from './COMMANDS_HANDLERS.js'; import { type AppConfig } from 'application-services'; import initRoutesWrappers, { ROUTES_WRAPPERS_REG_EXP, type WhookRoutesWrappersConfig, } from './ROUTES_WRAPPERS.js'; import initCronsWrappers, { CRONS_WRAPPERS_REG_EXP, type WhookCronsWrappersConfig, } from './CRONS_WRAPPERS.js'; import initConsumersWrappers, { CONSUMERS_WRAPPERS_REG_EXP, type WhookConsumersWrappersConfig, } from './CONSUMERS_WRAPPERS.js'; import initTransformersWrappers, { TRANSFORMERS_WRAPPERS_REG_EXP, type WhookTransformersWrappersConfig, } from './TRANSFORMERS_WRAPPERS.js'; import { extname, join as pathJoin } from 'node:path'; import { access as _access, constants } from 'node:fs/promises'; import { YError, printStackTrace } from 'yerror'; import { type ResolveService, type ImporterService, type LogService, } from 'common-services'; import { WHOOK_DEFAULT_PLUGINS, WHOOK_PROJECT_PLUGIN_NAME, type WhookPluginName, type WhookResolvedPluginsService, } from './WHOOK_RESOLVED_PLUGINS.js'; import { type WhookRawCommandArgs } from '../libs/args.js'; import { DEFAULT_CRONS_DEFINITIONS_OPTIONS, type WhookCronDefinitionsOptions, type WhookCronsDefinitionsService, } from './CRONS_DEFINITIONS.js'; import { DEFAULT_ROUTES_DEFINITIONS_OPTIONS, type WhookRoutesDefinitionsOptions, type WhookRoutesDefinitionsService, } from './ROUTES_DEFINITIONS.js'; import { DEFAULT_CONSUMERS_DEFINITIONS_OPTIONS, type WhookConsumerDefinitionsOptions, type WhookConsumersDefinitionsService, } from './CONSUMERS_DEFINITIONS.js'; import { DEFAULT_TRANSFORMERS_DEFINITIONS_OPTIONS, type WhookTransformerDefinitionsOptions, type WhookTransformersDefinitionsService, } from './TRANSFORMERS_DEFINITIONS.js'; import { type WhookCommandsDefinitionsOptions, type WhookCommandsDefinitionsService, DEFAULT_COMMANDS_DEFINITIONS_OPTIONS, } from './COMMANDS_DEFINITIONS.js'; import { type WhookCommandModule } from '../types/commands.js'; const DEFAULT_INITIALIZER_PATH_MAP = {}; /* Architecture Note #2.9.3: the `INITIALIZER_PATH_MAP` mapper The Whook auto-loader allows you to provide the file path of a service per its name. It exports a `WhookInitializerMap` type to help you ensure yours are valid. */ export type WhookInitializerMap = Record<string, string>; export type WhookAutoloadDependencies = WhookRoutesWrappersConfig & WhookCronsWrappersConfig & WhookConsumersWrappersConfig & WhookTransformersWrappersConfig & { APP_CONFIG?: AppConfig; INITIALIZER_PATH_MAP?: WhookInitializerMap; WHOOK_PLUGINS?: WhookPluginName[]; WHOOK_RESOLVED_PLUGINS: WhookResolvedPluginsService; COMMANDS_DEFINITIONS_OPTIONS?: WhookCommandsDefinitionsOptions; ROUTES_DEFINITIONS_OPTIONS?: WhookRoutesDefinitionsOptions; CRONS_DEFINITIONS_OPTIONS?: WhookCronDefinitionsOptions; CONSUMERS_DEFINITIONS_OPTIONS?: WhookConsumerDefinitionsOptions; TRANSFORMERS_DEFINITIONS_OPTIONS?: WhookTransformerDefinitionsOptions; args: WhookRawCommandArgs; $injector: Injector<Service>; importer: ImporterService<WhookCommandModule>; resolve: ResolveService; access?: typeof _access; log?: LogService; }; /* Architecture Note #2.9: the `$autoload` service Whook provides a simple way to load the constants, services and handlers of a project automatically though several strategies. It is done by implementing the `knifecycle` auto loading interface. */ export default singleton(name('$autoload', autoService(initAutoload))); /** * Initialize the Whook default DI autoloader * @param {Object} services * The service dependencies * @param {Array} [services.APP_CONFIG] * Optional APP_CONFIG object to inject * @param {Object} [services.INITIALIZER_PATH_MAP] * An optional map of paths mapping initializers * @param {Array<String>} [services.WHOOK_PLUGINS] * The activated plugins * @param {Array} services.WHOOK_RESOLVED_PLUGINS * The resolved plugins * @param {Array} [services.ROUTES_WRAPPERS_NAMES] * The global wrappers names to wrap the handlers with * @param {Object} services.$injector * An optional object to map services names to other names * @param {Object} services.importer * A service allowing to dynamically import ES modules * @param {Object} services.resolve * A service allowing to dynamically resolve ES modules * @param {Object} services.access * A service allowing to verify access to a file * @param {Object} [services.log=noop] * An optional logging service * @return {Promise<Function>} * A promise of the autoload function. */ async function initAutoload({ APP_CONFIG = undefined, INITIALIZER_PATH_MAP = DEFAULT_INITIALIZER_PATH_MAP, WHOOK_PLUGINS = WHOOK_DEFAULT_PLUGINS, WHOOK_RESOLVED_PLUGINS, ROUTES_WRAPPERS_NAMES = [], CRONS_WRAPPERS_NAMES = [], CONSUMERS_WRAPPERS_NAMES = [], TRANSFORMERS_WRAPPERS_NAMES = [], ROUTES_DEFINITIONS_OPTIONS = DEFAULT_ROUTES_DEFINITIONS_OPTIONS, CRONS_DEFINITIONS_OPTIONS = DEFAULT_CRONS_DEFINITIONS_OPTIONS, CONSUMERS_DEFINITIONS_OPTIONS = DEFAULT_CONSUMERS_DEFINITIONS_OPTIONS, TRANSFORMERS_DEFINITIONS_OPTIONS = DEFAULT_TRANSFORMERS_DEFINITIONS_OPTIONS, COMMANDS_DEFINITIONS_OPTIONS = DEFAULT_COMMANDS_DEFINITIONS_OPTIONS, args, importer, $injector, resolve, access = _access, log = noop, }: WhookAutoloadDependencies): Promise< Autoloader<Initializer<unknown, Dependencies>> > { log('warning', '🤖 - Initializing the `$autoload` service.'); /* Architecture Note #2.9.2: the `API` auto loading We cannot inject the `API` in the auto loader since it is dynamically loaded so doing this during the auto loader initialization. */ let ROUTES_DEFINITIONS: WhookRoutesDefinitionsService; const getRoutesDefinitions = (() => { return async () => { if (!ROUTES_DEFINITIONS) { ROUTES_DEFINITIONS = (await $injector(['ROUTES_DEFINITIONS'])) .ROUTES_DEFINITIONS; } return ROUTES_DEFINITIONS; }; })(); let CRONS_DEFINITIONS: WhookCronsDefinitionsService; const getCronsDefinitions = (() => { return async () => { if (!CRONS_DEFINITIONS) { CRONS_DEFINITIONS = (await $injector(['CRONS_DEFINITIONS'])) .CRONS_DEFINITIONS; } return CRONS_DEFINITIONS; }; })(); let CONSUMERS_DEFINITIONS: WhookConsumersDefinitionsService; const getConsumersDefinitions = (() => { return async () => { if (!CONSUMERS_DEFINITIONS) { CONSUMERS_DEFINITIONS = (await $injector(['CONSUMERS_DEFINITIONS'])) .CONSUMERS_DEFINITIONS; } return CONSUMERS_DEFINITIONS; }; })(); let TRANSFORMERS_DEFINITIONS: WhookTransformersDefinitionsService; const getTransformersDefinitions = (() => { return async () => { if (!TRANSFORMERS_DEFINITIONS) { TRANSFORMERS_DEFINITIONS = ( await $injector(['TRANSFORMERS_DEFINITIONS']) ).TRANSFORMERS_DEFINITIONS; } return TRANSFORMERS_DEFINITIONS; }; })(); let COMMANDS_DEFINITIONS: WhookCommandsDefinitionsService; const getCommandsDefinitions = (() => { return async () => { if (!COMMANDS_DEFINITIONS) { COMMANDS_DEFINITIONS = (await $injector(['COMMANDS_DEFINITIONS'])) .COMMANDS_DEFINITIONS; } return COMMANDS_DEFINITIONS; }; })(); let commandModule: WhookCommandModule; const getCommandModule = (() => { return async () => { if (!commandModule) { const commandName = args.rest[0]; // This is not supposed to happen if (!commandName) { log('warning', `❌ - No command given in argument.`); throw new YError('E_NO_COMMAND_NAME'); } for (const pluginName of WHOOK_PLUGINS) { const resolvedPlugin = WHOOK_RESOLVED_PLUGINS[pluginName]; const finalPath = new URL( pathJoin( '.', 'commands', commandName + extname(resolvedPlugin.mainURL), ), resolvedPlugin.mainURL, ).toString(); try { commandModule = await importer(finalPath); break; } catch (err) { log( 'debug', `❌ - Command "${commandName}" not found in "${finalPath}".`, ); log('debug-stack', printStackTrace(err)); } } if (!commandModule) { throw new YError('E_BAD_COMMAND_NAME', [commandName]); } if (!commandModule.default) { throw new YError('E_NO_COMMAND_HANDLER', [commandName]); } if (!commandModule.definition) { throw new YError('E_NO_COMMAND_DEFINITION', [commandName]); } } return commandModule; }; })(); return $autoload; /** * Autoload an initializer from its name * @param {String} The dependency name * @return {Promise<Object>} * A promise resolving with the actual autoloader definition. * An Object containing the `path`, `name` and the `initializer` * in its properties. */ async function $autoload( injectedName: string, ): Promise<Initializer<Service, Dependencies>> { /* Architecture Note #2.9.1: the `APP_CONFIG` mapper First of all the autoloader looks for constants in the previously loaded `APP_CONFIG` configurations hash. */ if (APP_CONFIG && hasDefinedKey(APP_CONFIG, injectedName)) { log( 'debug', `📖 - Picking the "${injectedName}" constant in the "APP_CONFIG" service properties.`, ); return constant(injectedName, APP_CONFIG[injectedName]); } const isRouteHandler = ROUTES_DEFINITIONS_OPTIONS.serviceNamePatterns.some( (pattern) => new RegExp(pattern).test(injectedName), ); const isRouteWrapper = ROUTES_WRAPPERS_REG_EXP.test(injectedName); const isCronHandler = CRONS_DEFINITIONS_OPTIONS.serviceNamePatterns.some( (pattern) => new RegExp(pattern).test(injectedName), ); const isConsumerHandler = CONSUMERS_DEFINITIONS_OPTIONS.serviceNamePatterns.some((pattern) => new RegExp(pattern).test(injectedName), ); const isTransformerHandler = TRANSFORMERS_DEFINITIONS_OPTIONS.serviceNamePatterns.some((pattern) => new RegExp(pattern).test(injectedName), ); const isCommandHandler = COMMANDS_DEFINITIONS_OPTIONS.serviceNamePatterns.some((pattern) => new RegExp(pattern).test(injectedName), ); const isCronWrapper = CRONS_WRAPPERS_REG_EXP.test(injectedName); const isConsumerWrapper = CONSUMERS_WRAPPERS_REG_EXP.test(injectedName); const isTransformerWrapper = TRANSFORMERS_WRAPPERS_REG_EXP.test(injectedName); /* Architecture Note #2.9.4: the `ROUTES_HANDLERS` mapper Here, we build the handlers map needed by the router by injecting every handler required by the API. */ if ('ROUTES_HANDLERS' === injectedName) { const handlerNames = Object.keys(await getRoutesDefinitions()); return location( alsoInject(handlerNames, initRoutesHandlers), '@whook/whook/dist/services/ROUTES_HANDLERS.js', ) as Initializer<Dependencies, Service>; } if ('CRONS_HANDLERS' === injectedName) { const handlerNames = Object.keys(await getCronsDefinitions()); return location( alsoInject(handlerNames, initCronsHandlers), '@whook/whook/dist/services/CRONS_HANDLERS.js', ) as Initializer<Dependencies, Service>; } if ('CONSUMERS_HANDLERS' === injectedName) { const handlerNames = Object.keys(await getConsumersDefinitions()); return location( alsoInject(handlerNames, initConsumersHandlers), '@whook/whook/dist/services/CONSUMERS_HANDLERS.js', ) as Initializer<Dependencies, Service>; } if ('TRANSFORMERS_HANDLERS' === injectedName) { const handlerNames = Object.keys(await getTransformersDefinitions()); return location( alsoInject(handlerNames, initTransformersHandlers), '@whook/whook/dist/services/TRANSFORMERS_HANDLERS.js', ) as Initializer<Dependencies, Service>; } if ('COMMANDS_HANDLERS' === injectedName) { const handlerNames = Object.keys(await getCommandsDefinitions()); return location( alsoInject(handlerNames, initCommandsHandlers), '@whook/whook/dist/services/COMMANDS_HANDLERS.js', ) as Initializer<Dependencies, Service>; } /* Architecture Note #2.9.5: the `ROUTES_WRAPPERS` auto loading We inject the `ROUTES_WRAPPERS_NAMES` in the `ROUTES_WRAPPERS` service so that they can be dynamically applied. */ if ('ROUTES_WRAPPERS' === injectedName) { return location( alsoInject(ROUTES_WRAPPERS_NAMES, initRoutesWrappers), '@whook/whook/dist/services/ROUTES_WRAPPERS.js', ); } if ('CRONS_WRAPPERS' === injectedName) { return location( alsoInject(CRONS_WRAPPERS_NAMES, initCronsWrappers), '@whook/whook/dist/services/CRONS_WRAPPERS.js', ); } if ('CONSUMERS_WRAPPERS' === injectedName) { return location( alsoInject(CONSUMERS_WRAPPERS_NAMES, initConsumersWrappers), '@whook/whook/dist/services/CONSUMERS_WRAPPERS.js', ); } if ('TRANSFORMERS_WRAPPERS' === injectedName) { return location( alsoInject(TRANSFORMERS_WRAPPERS_NAMES, initTransformersWrappers), '@whook/whook/dist/services/TRANSFORMERS_WRAPPERS.js', ); } if (injectedName === 'COMMAND_DEFINITION') { return constant( 'COMMAND_DEFINITION', (await getCommandModule()).definition, ); } if (injectedName === 'commandHandler') { return name( 'commandHandler', (await getCommandModule()).default as ServiceInitializer< Dependencies, Service >, ); } /* Architecture Note #2.9.6: Service/handler/wrapper loading Finally, we either load the handler/service/wrapper module if none of the previous strategies applied. */ let modulePath = ''; /* Architecture Note #2.9.3.1: Initializer path mapping In order to be able to load a service from a given path map one can directly specify a path to use for its resolution. */ if (INITIALIZER_PATH_MAP?.[injectedName]) { log( 'debug', `📖 - Using "INITIALIZER_PATH_MAP" to resolve the "${injectedName}" module path.`, ); modulePath = INITIALIZER_PATH_MAP[injectedName].startsWith('.') ? resolve(INITIALIZER_PATH_MAP[injectedName]) : INITIALIZER_PATH_MAP[injectedName]; } /* Architecture Note #2.9.6.2: Plugins/project paths Trying to load services from plugins/project paths. */ for (const pluginName of WHOOK_PLUGINS) { if (modulePath) { continue; } const resolvedPlugin = WHOOK_RESOLVED_PLUGINS[pluginName]; const finalPath = new URL( pathJoin( isCronHandler ? 'crons' : isConsumerHandler ? 'consumers' : isTransformerHandler ? 'transformers' : isRouteHandler ? 'routes' : isCommandHandler ? 'commands' : isRouteWrapper || isCronWrapper || isConsumerWrapper || isTransformerWrapper ? 'wrappers' : 'services', '.', (isCommandHandler ? injectedName.replace(/Command$/, '') : injectedName) + extname(resolvedPlugin.mainURL), ), resolvedPlugin.mainURL, ); log( 'debug', `🍀 - Trying to find "${injectedName}" module path in "${pluginName}".`, ); modulePath = (await checkAccess({ log, access }, finalPath)).toString(); if (pluginName !== WHOOK_PROJECT_PLUGIN_NAME) { // TODO: This assumes a `dist`/`src` folder which // is not necessarily true (`..`) const prefix = new URL('..', resolvedPlugin.mainURL).toString(); if ( modulePath.startsWith( new URL('..', resolvedPlugin.mainURL).toString(), ) ) { modulePath = modulePath.replace(prefix, pluginName + '/'); } } } if (modulePath) { log( 'debug', `✅ - Module path of "${injectedName}" found at "${modulePath}".`, ); } else { log('debug', `🚫 - Module path of "${injectedName}" not found.`); throw new YError('E_UNMATCHED_DEPENDENCY', [injectedName]); } log('debug', `💿 - Service "${injectedName}" found in "${modulePath}".`); const resolvedInitializer = (await importer(modulePath)) .default as ProviderInitializer<Dependencies, Service>; log( 'debug', `💿 - Loading "${injectedName}" initializer from "${modulePath}".`, ); const renamedInitializer = injectedName !== injectedName ? name(injectedName, resolvedInitializer) : resolvedInitializer; return location(renamedInitializer, modulePath, 'default'); } } export async function checkAccess( { log, access = _access, }: { log: LogService; access?: typeof _access; }, url: URL, ): Promise<Parameters<NonNullable<typeof access>>[0]> { try { await access(url, constants.R_OK); return url; } catch (err) { log('debug', `🚫 - File doesn't exist at "${url}".`); log('debug-stack', printStackTrace(err)); return ''; } }