UNPKG

@whook/whook

Version:

Build strong and efficient REST web services.

626 lines (581 loc) 15.9 kB
import { location, autoService } from 'knifecycle'; import { noop } from '../libs/utils.js'; import { YError } from 'yerror'; import camelCase from 'camelcase'; import _inquirer from 'inquirer'; import path from 'node:path'; import { default as fsExtra } from 'fs-extra'; import { type LogService } from 'common-services'; import { type OpenAPI, PATH_ITEM_METHODS } from 'ya-open-api-types'; import { type WhookCommandHandler, type WhookCommandDefinition, } from '../types/commands.js'; import { DEFAULT_ROUTES_DEFINITIONS_OPTIONS, type WhookRoutesDefinitionsOptions, } from '../services/ROUTES_DEFINITIONS.js'; import { DEFAULT_CRONS_DEFINITIONS_OPTIONS, type WhookCronDefinitionsOptions, } from '../services/CRONS_DEFINITIONS.js'; const { writeFile: _writeFile, ensureDir: _ensureDir, pathExists: _pathExists, } = fsExtra; // Currently, we rely on a static list of services but // best would be to use TypeScript introspection and // the autoloader to allow to retrieve a dynamic list // of constants from the APP_CONFIG service, the AppConfig // type and the autoloader service. const commonServicesTypes = { codeGenerator: 'CodeGeneratorService', counter: 'CounterService', delay: 'DelayService', importer: 'ImporterService', lock: 'LockService', log: 'LogService', random: 'RandomService', resolve: 'ResolveService', time: 'TimeService', }; const applicationServicesTypes = { APP_CONFIG: 'AppConfig', ENV: 'AppEnvVars', process: 'ProcessService', PROJECT_DIR: 'ProjectDirService', }; const whookSimpleTypes = { MAIN_FILE_URL: 'string', DEBUG_NODE_ENVS: 'string[]', }; const whookServicesTypes = { BASE_URL: 'WhookBaseURL', HOST: 'WhookHost', PORT: 'WhookPort', DEFINITIONS: 'WhookDefinitions', APM: 'APMService', }; const allTypes = { ...commonServicesTypes, ...applicationServicesTypes, ...whookSimpleTypes, ...whookServicesTypes, }; export const definition = { name: 'create', description: 'A command helping to create new Whook files easily', example: `whook create --type service --name "db"`, config: { environments: ['development'], promptArgs: true, }, arguments: [ { name: 'type', description: 'Type', required: true, schema: { type: 'string', enum: ['route', 'service', 'provider', 'cron', 'command'], }, }, { name: 'name', description: 'Name', required: true, schema: { type: 'string', }, }, ], } as const satisfies WhookCommandDefinition; async function initCreateCommand({ PROJECT_DIR, ROUTES_DEFINITIONS_OPTIONS = DEFAULT_ROUTES_DEFINITIONS_OPTIONS, CRONS_DEFINITIONS_OPTIONS = DEFAULT_CRONS_DEFINITIONS_OPTIONS, API, inquirer = _inquirer, writeFile = _writeFile, ensureDir = _ensureDir, pathExists = _pathExists, log = noop, }: { PROJECT_DIR: string; ROUTES_DEFINITIONS_OPTIONS?: WhookRoutesDefinitionsOptions; CRONS_DEFINITIONS_OPTIONS?: WhookCronDefinitionsOptions; API: OpenAPI; inquirer: typeof _inquirer; writeFile: (path: string, data: string) => Promise<void>; ensureDir: typeof _ensureDir; pathExists: typeof _pathExists; log?: LogService; }): Promise<WhookCommandHandler<{ type: string; name: string }>> { return async (args) => { const { namedArguments: { type, name }, } = args; const finalName = camelCase(name); if (name !== finalName) { log('warning', `🐪 - Camelized the name "${finalName}".`); } if ( type === 'route' && !ROUTES_DEFINITIONS_OPTIONS.serviceNamePatterns.some((pattern) => new RegExp(pattern).test(finalName), ) ) { log( 'error', `💥 - The route name "${finalName}" does not match "${ROUTES_DEFINITIONS_OPTIONS.serviceNamePatterns}".`, ); throw new YError( 'E_BAD_HANDLER_NAME', finalName, ROUTES_DEFINITIONS_OPTIONS.serviceNamePatterns, ); } else if ( type === 'cron' && !CRONS_DEFINITIONS_OPTIONS.serviceNamePatterns.some((pattern) => new RegExp(pattern).test(finalName), ) ) { log( 'error', `💥 - The cron name "${finalName}" does not match "${CRONS_DEFINITIONS_OPTIONS.serviceNamePatterns}".`, ); throw new YError( 'E_BAD_HANDLER_NAME', finalName, CRONS_DEFINITIONS_OPTIONS.serviceNamePatterns, ); } const { services } = (await inquirer.prompt<{ services: string[]; }>([ { name: 'services', type: 'checkbox', message: 'Which services do you want to use?', choices: [ ...Object.keys(commonServicesTypes), ...Object.keys(applicationServicesTypes), ...Object.keys(whookSimpleTypes), ...Object.keys(whookServicesTypes), ].map((value) => ({ value })), }, ])) as { services: string[] }; const servicesTypes = services .sort() .map((name) => ({ name, type: allTypes[name], })) .concat( type === 'command' ? [{ name: 'promptArgs', type: 'WhookPromptArgs' }] : [], ); const parametersDeclaration = servicesTypes.length ? `{${servicesTypes .map( ({ name }) => ` ${name},`, ) .join('')} }` : ''; const typesDeclaration = servicesTypes.length ? `{${servicesTypes .map( ({ name, type }) => ` ${name}: ${type};`, ) .join('')} }` : ''; const commonServices = services.filter( (service) => commonServicesTypes[service], ); const applicationServices = services.filter( (service) => applicationServicesTypes[service], ); const whookServices = services.filter( (service) => whookServicesTypes[service], ); const imports = `${ type === 'command' ? ` import { type WhookCommand, type WhookCommandDefinition, } from '@whook/whook';` : '' }${ commonServices.length ? ` import { ${commonServices .map((name) => `type ${commonServicesTypes[name]}`) .join(', ')} } from 'common-services';` : '' }${ applicationServices.length ? ` import { ${applicationServices .map((name) => `type ${applicationServicesTypes[name]}`) .join(', ')} } from 'application-services';` : '' }${ whookServices.length ? ` import { ${whookServices .map((name) => `type ${whookServicesTypes[name]}`) .join(', ')} } from '@whook/whook';` : '' } `; let fileSource = ''; if (type === 'route') { const { method, path, description, tags } = await inquirer.prompt<{ method: string; path: string; description: string; tags: string[]; }>([ { name: 'method', type: 'list', message: 'Give the handler method', choices: PATH_ITEM_METHODS.map((method) => ({ name: method, value: method, })), default: ROUTES_DEFINITIONS_OPTIONS.serviceNamePatterns .map( (pattern) => (new RegExp(pattern).exec(finalName) as string[])[2], ) .filter((maybeMethod) => PATH_ITEM_METHODS.includes(maybeMethod as 'get'), )[0], }, { name: 'path', type: 'input', message: 'Give the handler path', }, { name: 'description', type: 'input', message: 'Give the handler description', }, ...(API.tags && API.tags.length ? ([ { name: 'tags', type: 'checkbox', message: 'Assign one or more tags to the handler', choices: (API.tags || []).map(({ name }) => ({ name, value: name, })), }, ] as const) : []), ]); fileSource = buildRouteSource( name, path, method, description, tags, parametersDeclaration, typesDeclaration, imports, ); } else if (type === 'cron') { fileSource = buildCronSource( name, parametersDeclaration, typesDeclaration, imports, ); } else if (type === 'service') { fileSource = buildServiceSource( name, parametersDeclaration, typesDeclaration, imports, ); } else if (type === 'provider') { fileSource = buildProviderSource( name, parametersDeclaration, typesDeclaration, imports, ); } else if (type === 'command') { const { description } = await inquirer.prompt<{ description: string }>([ { name: 'description', message: 'Give the command description', type: 'input', }, ]); fileSource = buildCommandSource( name, description, parametersDeclaration, typesDeclaration, imports, ); } else { throw new YError('E_UNEXPECTED_TYPE'); } const fileDir = path.join( PROJECT_DIR, 'src', ['service', 'provider'].includes(type) ? 'services' : type === 'route' ? 'routes' : type === 'cron' ? 'crons' : 'commands', ); await ensureDir(fileDir); const filePath = path.join(fileDir, `${name}.ts`); if (await pathExists(filePath)) { log('warning', '⚠️ - The file already exists !'); const { erase } = await inquirer.prompt<{ erase: boolean; }>([ { name: 'erase', message: 'Erase ?', type: 'confirm', }, ]); if (!erase) { return; } } await writeFile(filePath, fileSource); }; } function buildRouteSource( name: string, path: string, method: string, description = '', tags: string[] = [], parametersDeclaration: string, typesDeclaration: string, imports: string, ) { const handlerInitializerName = 'init' + name[0].toUpperCase() + name.slice(1); return `import { autoService, location } from 'knifecycle'; import { type WhookRouteDefinition, type WhookRouteTypedHandler, } from '@whook/whook';${imports} export const definition = { path: '${path}', method: '${method}', operation: { operationId: '${name}', summary: '${description.replace(/'/g, "\\'")}', tags: [${tags.map((tag) => `'${tag}'`).join(', ')}], parameters: [ { name: 'param', in: 'query', required: false, schema: { type: 'number' }, }, ],${ ['post', 'put', 'patch'].includes(method) ? ` requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', }, }, }, },` : '' } responses: { 200: { description: 'Success', content: { 'application/json': { schema: { type: 'object', }, }, }, }, }, }, } as const satisfies WhookRouteDefinition; export type HandlerDependencies = ${typesDeclaration || '{}'}; async function ${handlerInitializerName}(${parametersDeclaration || '_'}: HandlerDependencies) { const handler: WhookRouteTypedHandler< operations[typeof definition.operation.operationId], typeof definition > = async ({ query: { param },${ ['post', 'put'].includes(method) ? ` body,` : '' } }) => { return { status: 200, headers: {}, body: { param }, }; }; return handler; } export default location( autoService(${handlerInitializerName}), import.meta.url, ); `; } function buildCronSource( name: string, parametersDeclaration: string, typesDeclaration: string, imports: string, ) { const handlerInitializerName = 'init' + name[0].toUpperCase() + name.slice(1); return `import { autoService, location } from 'knifecycle'; import { type WhookCronDefinition, } from '@whook/whook';${imports} export const definition = { name: 'handleMinutes', schedules: [ { rule: '*/1 * * * *', // Bodies provided here are type checked ;) body: { message: 'A minute starts!', delay: 10000 }, enabled: true, }, ], schema: { type: 'string' }, } as const satisfies WhookCronDefinition; export type CronHandlerDependencies = ${typesDeclaration || '{}'}; async function ${handlerInitializerName}(${parametersDeclaration || '_'}: CronHandlerDependencies) { const handler: WhookCronHandler<string> = async ({ date, body, }) => { // Cron tasks goes here }; return handler; } export default location( autoService(${handlerInitializerName}), import.meta.url, ); `; } function buildServiceSource( name: string, parametersDeclaration: string, typesDeclaration: string, imports: string, ) { const upperCamelizedName = name[0].toLocaleUpperCase() + name.slice(1); return `import { autoService, location } from 'knifecycle';${imports} export type ${upperCamelizedName}Service = {}; export type ${upperCamelizedName}Dependencies = ${typesDeclaration || '{}'}; async function init${upperCamelizedName}(${ parametersDeclaration || '_' }: ${upperCamelizedName}Dependencies): Promise<${upperCamelizedName}Service> { // Instantiate and return your service return {}; } export default location( autoService(init${upperCamelizedName}), import.meta.url, ); `; } function buildProviderSource( name: string, parametersDeclaration: string, typesDeclaration: string, imports: string, ) { const upperCamelizedName = name[0].toLocaleUpperCase() + name.slice(1); return `import { autoProvider, location, type Provider } from 'knifecycle';${imports} export type ${upperCamelizedName}Service = {}; export type ${upperCamelizedName}Provider = Provider<${upperCamelizedName}Service>; export type ${upperCamelizedName}Dependencies = ${typesDeclaration || '{}'}; async function init${upperCamelizedName}(${ parametersDeclaration || '_' }: ${upperCamelizedName}Dependencies): Promise<${upperCamelizedName}Provider> { // Instantiate and return your service return { service: {}, dispose: async () => { // Do any action before the process shutdown // (closing db connections... etc) }, // You can also set a promise for unexpected errors // that shutdown the app when it happens // errorPromise: new Promise(), }; } export default location( autoProvider(init${upperCamelizedName}), import.meta.url, ); `; } function buildCommandSource( name: string, description: string, parametersDeclaration: string, typesDeclaration: string, imports: string, ) { const upperCamelizedName = name[0].toLocaleUpperCase() + name.slice(1); return `import { location, autoService } from 'knifecycle';${imports} export const definition = { name: '${name}', description: '${description.replace(/'/g, "\\'")}', example: \`whook ${name} --param "value"\`, arguments: [{ name: 'param', required: true, description: 'A parameter', schema: { type: 'string', default: 'A default value', }, }], } as const satisfies WhookCommandDefinition; async function init${upperCamelizedName}Command(${ parametersDeclaration || '_' }: ${typesDeclaration || {}}): Promise<WhookCommand<{ param: string; }>> { return async ({ param }) => { // Implement your command here } } export default location( autoService(init${upperCamelizedName}Command), import.meta.url, ); `; } export default location(autoService(initCreateCommand), import.meta.url);