From ad067aa98c4d0d411e7ba88545e257c4b1d21a3b Mon Sep 17 00:00:00 2001 From: p-mcgowan Date: Thu, 12 Jun 2025 08:41:47 +0200 Subject: [PATCH] feat: custom generator overrides closes #19 add custom generator overrides (-T, --templates ). can be a folder with override files, or a module (local or node module) containing exports. to disable generation completely, return an empty string. ref #20 --- package.json | 2 +- readme.md | 114 +++++ src/cli.ts | 169 ++++++- src/components/parameters/index.yml | 1 + src/components/schemas/index.yml | 1 + src/generate.ts | 33 +- src/lib.ts | 5 +- src/paths/index.yml | 1 + src/subcommands/init.ts | 12 +- src/subcommands/model.ts | 52 +- src/subcommands/path.ts | 73 ++- src/templates/init.ts | 14 +- src/templates/model.ts | 40 +- src/templates/path.ts | 53 +- test/boats.ts | 4 +- test/custom-models.spec.ts | 185 +++++++ test/fixtures/overrides/boats-rc.js | 9 + test/fixtures/overrides/component-index.js | 8 + test/fixtures/overrides/create.js | 11 + test/fixtures/overrides/delete.js | 11 + test/fixtures/overrides/index.js | 16 + test/fixtures/overrides/list.js | 22 + test/fixtures/overrides/model.js | 36 ++ test/fixtures/overrides/module.js | 14 + test/fixtures/overrides/pagination-model.js | 10 + test/fixtures/overrides/param.js | 15 + test/fixtures/overrides/path-index.js | 4 + test/fixtures/overrides/replace.js | 10 + test/fixtures/overrides/show.js | 23 + test/fixtures/overrides/update.js | 38 ++ test/fixtures/spec/custom.json | 513 ++++++++++++++++++++ test/path.spec.ts | 4 +- test/shared.ts | 2 +- 33 files changed, 1435 insertions(+), 70 deletions(-) create mode 100644 src/components/parameters/index.yml create mode 100644 src/components/schemas/index.yml create mode 100644 src/paths/index.yml create mode 100644 test/custom-models.spec.ts create mode 100644 test/fixtures/overrides/boats-rc.js create mode 100644 test/fixtures/overrides/component-index.js create mode 100644 test/fixtures/overrides/create.js create mode 100644 test/fixtures/overrides/delete.js create mode 100644 test/fixtures/overrides/index.js create mode 100644 test/fixtures/overrides/list.js create mode 100644 test/fixtures/overrides/model.js create mode 100644 test/fixtures/overrides/module.js create mode 100644 test/fixtures/overrides/pagination-model.js create mode 100644 test/fixtures/overrides/param.js create mode 100644 test/fixtures/overrides/path-index.js create mode 100644 test/fixtures/overrides/replace.js create mode 100644 test/fixtures/overrides/show.js create mode 100644 test/fixtures/overrides/update.js create mode 100644 test/fixtures/spec/custom.json diff --git a/package.json b/package.json index fe7d672..386024b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "prebuild": "rm -rf dist/*", "build": "tsc", - "lint": "eslint src test", + "lint": "eslint '{src,test}/**/*.ts'", "version": "npm verison --no-commit-hooks --no-git-tag-version", "setup": "git config core.hooksPath ./githooks", "docs": "npm run docs-readme && npm run docs-changelog", diff --git a/readme.md b/readme.md index b62ea56..46f70e0 100644 --- a/readme.md +++ b/readme.md @@ -73,6 +73,120 @@ npm i --save-dev @acrontum/boats-cli then you can just run `npx bc model test --dry-run`. +## Custom Generator Overrides + +You can override any of the generators by adding any of these files to a templates folder, and passing `--templates ` to the cli: +- boats-rc.js +- component-index.js +- create.js +- delete.js +- index.js +- list.js +- model.js +- models.js +- pagination-model.js +- param.js +- path-index.js +- replace.js +- show.js +- update.js + +Or, alternatively, exporting any of the following methods from a module (local file or node module): +```js +exports.getBoatsRc = (opts, file) => { /* ... */ }; +exports.getIndex = (opts, file) => { /* ... */ }; +exports.getComponentIndex = (opts, file) => { /* ... */ }; +exports.getModel = (opts, file) => { /* ... */ }; +exports.getModels = (opts, file) => { /* ... */ }; +exports.getParam = (opts, file) => { /* ... */ }; +exports.getPaginationModel = (opts, file) => { /* ... */ }; +exports.getPathIndex = (opts, file) => { /* ... */ }; +exports.getList = (opts, file) => { /* ... */ }; +exports.getCreate = (opts, file) => { /* ... */ }; +exports.getShow = (opts, file) => { /* ... */ }; +exports.getDelete = (opts, file) => { /* ... */ }; +exports.getUpdate = (opts, file) => { /* ... */ }; +exports.getReplace = (opts, file) => { /* ... */ }; +``` + +for exmaple, `templates/index.js` or `exports.getList`: +```js +// @ts-check +const { toYaml } = require('@acrontum/boats-cli/dist/src/lib'); + +/** @type{import('@acrontum/boats-cli/').CustomTemplates['getList']} */ +module.exports = (_globalOptions, file, pluralName, schemaRef, parameters) => { + return toYaml({ + summary: `from ${file}`, + description: `pluralName ${pluralName}`, + ...(parameters?.length ? { parameters } : {}), + responses: { + '"200"': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: schemaRef }, + }, + }, + }, + }, + }); +}; + +```` + +or disabling the default generator and instead creating 2 different files for models `templates/model.yml` or `exports.getModel`: +```js +// @ts-check +const { toYaml } = require('@acrontum/boats-cli/dist/src/lib'); +const { writeFile, mkdir } = require('node:fs/promises'); +const { dirname, join } = require('node:path'); + +/** @type{import('@acrontum/boats-cli/').CustomTemplates['getModel']} */ +module.exports = async (globalOptions, file) => { + const base = join(globalOptions.output || '.', file.replace('model.yml', 'base.yml')); + const extend = join(globalOptions.output || '.', file.replace('model.yml', 'extend.yml')); + + await mkdir(dirname(base), { recursive: true }); + + await Promise.all([ + writeFile( + base, + toYaml({ + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + }, + }), + ), + writeFile( + extend, + toYaml({ + allOf: [ + { $ref: './base.yml' }, + { + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid', + }, + }, + }, + ], + }), + ), + ]); + + // prevent default generation + return ''; +}; +``` + +see [custom-models.spec.ts](./test/custom-models.spec.ts) and the [overrides folder]('./test/fixtures/overrides/') for more examples. + + ## Development Configure githooks: diff --git a/src/cli.ts b/src/cli.ts index 98caf36..48374ec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { mkdir } from 'node:fs/promises'; -import { relative } from 'node:path'; +import { mkdir, access, stat, readdir } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; import { parseArgs, ParseArgsConfig } from 'node:util'; import { generate, GenerationTask, logger } from './generate'; import { argname, description } from './lib'; @@ -9,6 +9,8 @@ import { parseInitCommand } from './subcommands/init'; import { modelCliArguments, parseModelCommand } from './subcommands/model'; import { parsePathCommand, pathCliArguments } from './subcommands/path'; import { getBoatsRc, getIndex } from './templates/init'; +import { getComponentIndex, getModel, getModels, getPaginationModel, getParam } from './templates/model'; +import { getCreate, getDelete, getList, getPathIndex, getReplace, getShow, getUpdate } from './templates/path'; export type ParseArgsOptionConfig = NonNullable[string]; export type CliArg = ParseArgsOptionConfig & { [description]: string; [argname]?: string }; @@ -22,8 +24,27 @@ export type GlobalOptions = { quiet?: boolean; verbose?: boolean; 'root-ref'?: string; + customTemplates?: { + getBoatsRc?: typeof getBoatsRc; + getIndex?: typeof getIndex; + + getComponentIndex?: typeof getComponentIndex; + getModel?: typeof getModel; + getModels?: typeof getModels; + getParam?: typeof getParam; + getPaginationModel?: typeof getPaginationModel; + + getPathIndex?: typeof getPathIndex; + getList?: typeof getList; + getCreate?: typeof getCreate; + getShow?: typeof getShow; + getDelete?: typeof getDelete; + getUpdate?: typeof getUpdate; + getReplace?: typeof getReplace; + }; }; export type SubcommandGenerator = (args: string[], options: GlobalOptions) => GenerationTask[] | null; +export type CustomTemplates = Exclude; /** * Custom error to immediately exit on errors. @@ -45,8 +66,35 @@ const subcommands: Record = { init: parseInitCommand, }; +const templateFileMapping = { + 'boats-rc.js': 'getBoatsRc', + 'component-index.js': 'getComponentIndex', + 'create.js': 'getCreate', + 'delete.js': 'getDelete', + 'index.js': 'getIndex', + 'list.js': 'getList', + 'model.js': 'getModel', + 'models.js': 'getModels', + 'pagination-model.js': 'getPaginationModel', + 'param.js': 'getParam', + 'path-index.js': 'getPathIndex', + 'replace.js': 'getReplace', + 'show.js': 'getShow', + 'update.js': 'getUpdate', +} as const satisfies Record>; + export const cliArguments: Record = { + /* + -t --templates + create.js delete.js list.js model.js models.js pagination.js param.js replace.js show.js update.js + */ 'dry-run': { type: 'boolean', short: 'D', [description]: 'Print the changes to be made' }, + templates: { + type: 'string', + short: 'T', + [argname]: 'TEMPLATES', + [description]: 'Folder or module containing template overrides', + }, force: { type: 'boolean', short: 'f', [description]: 'Overwrite existing files' }, 'no-index': { type: 'boolean', short: 'I', [description]: 'Skip auto-creating index files, only models' }, 'no-init': { type: 'boolean', short: 'N', [description]: 'Skip auto-creating init files' }, @@ -114,6 +162,11 @@ Subcommands: model SINGULAR_NAME [...OPTIONS] init [-a] +If templates are used (-T, --templates), the following filenames / exports are used (files when path is a folder, export function name if is a module): + - ${Object.entries(templateFileMapping) + .map(([file, fn]) => `${file.padEnd(19, ' ')} - ${fn}`) + .join('\n - ')} + Examples: npx bc path users/:id --list --get --delete --patch --put @@ -153,6 +206,90 @@ export const parseCliArgs = ( } }; +const tryRequire = (path: string): GlobalOptions['customTemplates'] | null => { + const overrides: Exclude = {}; + let lib: Exclude | null = null; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + lib = require(path) as Exclude; + } catch (_) {} + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + lib = require(resolve(path)) as Exclude; + } catch (_) {} + + if (lib) { + overrides.getBoatsRc = lib.getBoatsRc; + overrides.getIndex = lib.getIndex; + overrides.getComponentIndex = lib.getComponentIndex; + overrides.getModel = lib.getModel; + overrides.getModels = lib.getModels; + overrides.getParam = lib.getParam; + overrides.getPaginationModel = lib.getPaginationModel; + overrides.getPathIndex = lib.getPathIndex; + overrides.getList = lib.getList; + overrides.getCreate = lib.getCreate; + overrides.getShow = lib.getShow; + overrides.getDelete = lib.getDelete; + overrides.getUpdate = lib.getUpdate; + overrides.getReplace = lib.getReplace; + + if (!Object.values(overrides).find((v) => typeof v !== 'undefined')) { + logger.console.error(`cannot load templates "${path}": module has no override exports\n`); + + return help(1); + } + + return overrides; + } + + logger.console.error(`cannot load templates "${path}": not a module\n`); + + return help(1); +}; + +const getTemplates = async (path: string): Promise => { + const overrides: Exclude = {}; + const fullPath = resolve(path); + + const accessible = await access(fullPath) + .then(() => true) + .catch(() => false); + if (!accessible) { + return tryRequire(path); + } + + const folder = await stat(fullPath).catch(() => null); + if (folder === null) { + return tryRequire(path); + } + + if (!folder.isDirectory()) { + return tryRequire(path); + } + + const files = await readdir(fullPath).catch(() => null); + if (files === null) { + logger.console.error(`cannot load templates "${path}": could not read template folder contents\n`); + + return help(1); + } + + const matchingFiles = files.filter((file) => file in templateFileMapping) as (keyof typeof templateFileMapping)[]; + if (matchingFiles.length === 0) { + logger.console.error(`cannot load templates "${path}": template folder has no override files\n`); + + return help(1); + } + + for (const file of matchingFiles) { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + overrides[templateFileMapping[file]] = require(join(fullPath, file)); + } + + return overrides; +}; + export const cli = async (args: string[]): Promise> => { const processed = parseCliArgs( { @@ -176,6 +313,7 @@ export const cli = async (args: string[]): Promise] = true; } @@ -266,8 +415,18 @@ export const cli = async (args: string[]): Promise getIndex(globalOptions), filename: 'src/index.yml' }, { contents: getBoatsRc, filename: '.boatsrc' }); + tasks.push( + { contents: () => getIndex(globalOptions, 'src/index.yml'), filename: 'src/index.yml' }, + { contents: () => getBoatsRc(globalOptions, '.boatsrc'), filename: '.boatsrc' }, + ); } if (!tasks.length) { @@ -291,6 +450,8 @@ export const cli = async (args: string[]): Promise string; + contents: () => string | PromiseLike; filename: string; generate?: boolean; undo?: () => Promise; + skipped?: boolean; } export const logger = { console: console as Console & { logs?: unknown[]; clear?: () => void } }; @@ -18,22 +19,33 @@ export const logger = { console: console as Console & { logs?: unknown[]; clear? const logCreate = '\x1b[32m[create]\x1b[0m '; const logExists = '\x1b[33m[exists]\x1b[0m '; const logOverwrite = '\x1b[31m[replace]\x1b[0m'; +const logSkipped = '\x1b[36m[skipped]\x1b[0m'; const runTask = async (task: GenerationTask, options: GlobalOptions = {}): Promise => { const { contents, filename } = task; const fileExists = await exists(filename); const prefix = fileExists ? (options.force ? logOverwrite : logExists) : logCreate; + const output = await contents(); + if (!output) { + task.skipped = true; + if (!options.quiet) { + logger.console.info(logSkipped, filename); + } + + return task; + } + if (!options.quiet) { logger.console.info(prefix, filename); } if (options.verbose) { - logger.console.info(`${contents()}\n`); + logger.console.info(`${output}\n`); } if (!options['dry-run']) { - const createdFolder = await mkdir(dirname(filename)); + const createdFolder = await mkdirp(dirname(filename)); if (!fileExists) { task.undo = async (): Promise => { @@ -48,7 +60,7 @@ const runTask = async (task: GenerationTask, options: GlobalOptions = {}): Promi }; } - await createFile(filename, contents(), options.force); + await createFile(filename, output, options.force); } return task; @@ -61,22 +73,25 @@ export const generate = async (tasks: GenerationTask[], options: GlobalOptions = try { for (const task of tasks) { - task.filename = join(output, task.filename); if (task.generate === false) { continue; } + task.filename = join(output, task.filename); mapped[task.filename] = task; if (!options['no-index']) { if (task.filename.indexOf('src/components/schemas/') !== -1) { const filename = join(output, 'src/components/schemas/index.yml'); - mapped[filename] ||= { contents: (): string => getComponentIndex(options['root-ref']), filename }; + mapped[filename] ||= { contents: (): string => getComponentIndex(options, 'src/components/schemas/index.yml'), filename }; } else if (task.filename.indexOf('src/components/parameters/') !== -1) { const filename = join(output, 'src/components/parameters/index.yml'); - mapped[filename] ||= { contents: (): string => getComponentIndex(''), filename }; + mapped[filename] ||= { + contents: (): string => getComponentIndex({ ...options, 'root-ref': '' }, 'src/components/parameters/index.yml'), + filename, + }; } else if (task.filename.indexOf('src/paths/') !== -1) { const filename = join(output, 'src/paths/index.yml'); - mapped[filename] ||= { contents: (): string => getPathIndex(), filename }; + mapped[filename] ||= { contents: (): string => getPathIndex(options, 'src/paths/index.yml'), filename }; } } } diff --git a/src/lib.ts b/src/lib.ts index 9736923..bbb4117 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -1,11 +1,10 @@ -import { access, mkdir as fsmkdir, writeFile } from 'node:fs/promises'; +import { access, mkdir, writeFile } from 'node:fs/promises'; /* eslint-disable-next-line @typescript-eslint/no-empty-object-type */ export interface JsonArray extends Array {} export interface Json { [x: string]: string | number | boolean | Json | JsonArray | null; } - const endings: Record = { ves: 'fe', ies: 'y', @@ -42,7 +41,7 @@ export const exists = (path: string): Promise => .then(() => true) .catch(() => false); -export const mkdir = async (path: string): Promise => fsmkdir(path, { recursive: true }); +export const mkdirp = async (path: string): Promise => mkdir(path, { recursive: true }); export const createFile = async (path: string, content: string, force = false): Promise => { if (!force && (await exists(path))) { diff --git a/src/paths/index.yml b/src/paths/index.yml new file mode 100644 index 0000000..1267b77 --- /dev/null +++ b/src/paths/index.yml @@ -0,0 +1 @@ +{{ autoPathIndexer() }} diff --git a/src/subcommands/init.ts b/src/subcommands/init.ts index 8375c7f..845fce1 100644 --- a/src/subcommands/init.ts +++ b/src/subcommands/init.ts @@ -49,9 +49,15 @@ export const parseInitCommand: SubcommandGenerator = (args: string[], options: G case '--all': case '-a': tasks.push( - { contents: () => getComponentIndex(options['root-ref']), filename: 'src/components/schemas/index.yml' }, - { contents: () => getComponentIndex(''), filename: 'src/components/parameters/index.yml' }, - { contents: getPathIndex, filename: 'src/paths/index.yml' }, + { + contents: () => getComponentIndex(options, 'src/components/schemas/index.yml'), + filename: 'src/components/schemas/index.yml', + }, + { + contents: () => getComponentIndex({ 'root-ref': '' }, 'src/components/parameters/index.yml'), + filename: 'src/components/parameters/index.yml', + }, + { contents: () => getPathIndex(options, 'src/paths/index.yml'), filename: 'src/paths/index.yml' }, ); break; default: diff --git a/src/subcommands/model.ts b/src/subcommands/model.ts index 61262d3..d556376 100644 --- a/src/subcommands/model.ts +++ b/src/subcommands/model.ts @@ -114,41 +114,69 @@ export const getModelTasks = (options: ModelGenerationOptions): GenerationTask[] const tasks: GenerationTask[] = []; tasks.push({ - contents: () => getModel(options.globalOptions), + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/model.yml`), filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-model'] && !options.type, }); const paginationRef = getRootRef('../pagination/model.yml', '#/components/schemas/PaginationModel', options.globalOptions['root-ref']); if (options.type) { + const filename = `src/components/parameters/${options.type}${capitalize(camelName)}.yml`; tasks.push({ - contents: () => getParam(options.globalOptions, options.name, options.type as Exclude<(typeof options)['type'], undefined>), - filename: `src/components/parameters/${options.type}${capitalize(camelName)}.yml`, + contents: () => getParam(options.globalOptions, filename, options.name, options.type as Exclude<(typeof options)['type'], undefined>), + filename, }); } if (options.list || options.crud) { tasks.push( - { contents: () => getModels(options.globalOptions, paginationRef), filename: `src/components/schemas/${dashName}/models.yml` }, - { contents: () => getParam(options.globalOptions, 'limit', 'query', 'integer'), filename: `src/components/parameters/queryLimit.yml` }, - { contents: () => getParam(options.globalOptions, 'offset', 'query', 'integer'), filename: `src/components/parameters/queryOffset.yml` }, - { contents: () => getPaginationModel(options.globalOptions), filename: `src/components/schemas/pagination/model.yml` }, + { + contents: () => getModels(options.globalOptions, `src/components/schemas/${dashName}/models.yml`, paginationRef), + filename: `src/components/schemas/${dashName}/models.yml`, + }, + { + contents: () => getParam(options.globalOptions, `src/components/parameters/queryLimit.yml`, 'limit', 'query', 'integer'), + filename: `src/components/parameters/queryLimit.yml`, + }, + { + contents: () => getParam(options.globalOptions, `src/components/parameters/queryOffset.yml`, 'offset', 'query', 'integer'), + filename: `src/components/parameters/queryOffset.yml`, + }, + { + contents: () => getPaginationModel(options.globalOptions, `src/components/schemas/pagination/model.yml`), + filename: `src/components/schemas/pagination/model.yml`, + }, ); } if (options.get || options.crud) { - tasks.push({ contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/get.yml` }); + tasks.push({ + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/get.yml`), + filename: `src/components/schemas/${dashName}/get.yml`, + }); } if (options.delete || options.crud) { - tasks.push({ contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/delete.yml` }); + tasks.push({ + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/delete.yml`), + filename: `src/components/schemas/${dashName}/delete.yml`, + }); } if (options.patch || options.crud) { - tasks.push({ contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/patch.yml` }); + tasks.push({ + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/patch.yml`), + filename: `src/components/schemas/${dashName}/patch.yml`, + }); } if (options.post || options.crud) { - tasks.push({ contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/post.yml` }); + tasks.push({ + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/post.yml`), + filename: `src/components/schemas/${dashName}/post.yml`, + }); } if (options.put) { - tasks.push({ contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/put.yml` }); + tasks.push({ + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/put.yml`), + filename: `src/components/schemas/${dashName}/put.yml`, + }); } return tasks; diff --git a/src/subcommands/path.ts b/src/subcommands/path.ts index edce60c..8e1293c 100644 --- a/src/subcommands/path.ts +++ b/src/subcommands/path.ts @@ -153,7 +153,7 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = if (pathParam) { lastIsParam = true; tasks.push({ - contents: () => getParam(options.globalOptions, pathParam, 'path'), + contents: () => getParam(options.globalOptions, `src/components/parameters/path${capitalize(pathParam)}.yml`, pathParam, 'path'), filename: `src/components/parameters/path${capitalize(pathParam)}.yml`, generate: !options['no-models'], }); @@ -195,27 +195,31 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = tasks.push( { - contents: () => getParam(options.globalOptions, 'limit', 'query', 'integer'), + contents: () => getParam(options.globalOptions, `src/components/parameters/queryLimit.yml`, 'limit', 'query', 'integer'), filename: `src/components/parameters/queryLimit.yml`, generate: !options['no-models'], }, { - contents: () => getParam(options.globalOptions, 'offset', 'query', 'integer'), + contents: () => getParam(options.globalOptions, 'src/components/parameters/queryOffset.yml', 'offset', 'query', 'integer'), filename: 'src/components/parameters/queryOffset.yml', generate: !options['no-models'], }, { - contents: () => getPaginationModel(options.globalOptions), + contents: () => getPaginationModel(options.globalOptions, 'src/components/schemas/pagination/model.yml'), filename: 'src/components/schemas/pagination/model.yml', generate: !options['no-models'], }, - { contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-models'] }, { - contents: () => getModels(options.globalOptions, paginationRef), + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/model.yml`), + filename: `src/components/schemas/${dashName}/model.yml`, + generate: !options['no-models'], + }, + { + contents: () => getModels(options.globalOptions, `src/components/schemas/${dashName}/models.yml`, paginationRef), filename: `src/components/schemas/${dashName}/models.yml`, generate: !options['no-models'], }, - { contents: () => getList(options.globalOptions, customModelName, listSchemaRef, paramRefs), filename }, + { contents: () => getList(options.globalOptions, filename, customModelName, listSchemaRef, paramRefs), filename }, ); } @@ -234,9 +238,17 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = const paramRefs = mapParamRefs(otherPathParams, dirname(filename), rootRef); tasks.push( - { contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/post.yml`, generate: !options['no-models'] }, - { contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-models'] }, - { contents: () => getCreate(options.globalOptions, singularName, postRequestRef, postResponseRef, paramRefs), filename }, + { + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/post.yml`), + filename: `src/components/schemas/${dashName}/post.yml`, + generate: !options['no-models'], + }, + { + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/model.yml`), + filename: `src/components/schemas/${dashName}/model.yml`, + generate: !options['no-models'], + }, + { contents: () => getCreate(options.globalOptions, filename, singularName, postRequestRef, postResponseRef, paramRefs), filename }, ); } @@ -250,8 +262,12 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = const paramRefs = mapParamRefs(pathParams, dirname(filename), rootRef); tasks.push( - { contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-models'] }, - { contents: () => getShow(options.globalOptions, singularName, postResponseRef, paramRefs), filename }, + { + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/model.yml`), + filename: `src/components/schemas/${dashName}/model.yml`, + generate: !options['no-models'], + }, + { contents: () => getShow(options.globalOptions, filename, singularName, postResponseRef, paramRefs), filename }, ); } @@ -259,7 +275,7 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = const filename = `src/paths/${normalizedFilepath}/delete.yml`; const paramRefs = mapParamRefs(pathParams, dirname(filename), rootRef); - tasks.push({ contents: () => getDelete(options.globalOptions, singularName, paramRefs), filename }); + tasks.push({ contents: () => getDelete(options.globalOptions, filename, singularName, paramRefs), filename }); } if (options.patch) { @@ -277,9 +293,17 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = const paramRefs = mapParamRefs(pathParams, dirname(filename), rootRef); tasks.push( - { contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-models'] }, - { contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/patch.yml`, generate: !options['no-models'] }, - { contents: () => getUpdate(options.globalOptions, singularName, postRequestRef, postResponseRef, paramRefs), filename }, + { + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/model.yml`), + filename: `src/components/schemas/${dashName}/model.yml`, + generate: !options['no-models'], + }, + { + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/patch.yml`), + filename: `src/components/schemas/${dashName}/patch.yml`, + generate: !options['no-models'], + }, + { contents: () => getUpdate(options.globalOptions, filename, singularName, postRequestRef, postResponseRef, paramRefs), filename }, ); } @@ -298,9 +322,20 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = const paramRefs = mapParamRefs(pathParams, dirname(filename), rootRef); tasks.push( - { contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-models'] }, - { contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/put.yml`, generate: !options['no-models'] }, - { contents: () => getReplace(options.globalOptions, singularName, postRequestRef, postResponseRef, paramRefs), filename }, + { + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/model.yml`), + filename: `src/components/schemas/${dashName}/model.yml`, + generate: !options['no-models'], + }, + { + contents: () => getModel(options.globalOptions, `src/components/schemas/${dashName}/put.yml`), + filename: `src/components/schemas/${dashName}/put.yml`, + generate: !options['no-models'], + }, + { + contents: () => getReplace(options.globalOptions, filename, singularName, postRequestRef, postResponseRef, paramRefs), + filename, + }, ); } diff --git a/src/templates/init.ts b/src/templates/init.ts index 644335d..4bb3111 100644 --- a/src/templates/init.ts +++ b/src/templates/init.ts @@ -1,7 +1,7 @@ import { GlobalOptions } from '../cli'; import { toYaml } from '../lib'; -const autoTagOpid = ` +export const autoTagOpid = ` {{ inject([ { @@ -16,7 +16,11 @@ const autoTagOpid = ` ]) }}`; -export const getIndex = (globalOptions: GlobalOptions): string => { +export const getIndex = (globalOptions: GlobalOptions, file: string): string => { + if (typeof globalOptions.customTemplates?.getIndex === 'function') { + return globalOptions.customTemplates.getIndex(globalOptions, file); + } + const yaml = toYaml( { openapi: '3.1.0', @@ -41,7 +45,11 @@ export const getIndex = (globalOptions: GlobalOptions): string => { return `${yaml}${autoTagOpid}\n`; }; -export const getBoatsRc = (): string => { +export const getBoatsRc = (globalOptions: GlobalOptions, file: string): string => { + if (typeof globalOptions.customTemplates?.getBoatsRc === 'function') { + return globalOptions.customTemplates.getBoatsRc(globalOptions, file); + } + return ( JSON.stringify( { diff --git a/src/templates/model.ts b/src/templates/model.ts index 29d37cf..bd8ea7c 100644 --- a/src/templates/model.ts +++ b/src/templates/model.ts @@ -1,15 +1,23 @@ import { GlobalOptions } from '../cli'; import { toYaml } from '../lib'; -export const getComponentIndex = (rootRef?: string): string => { - if (rootRef === '-' || rootRef === '') { +export const getComponentIndex = (globalOptions: GlobalOptions, file: string): string => { + if (typeof globalOptions.customTemplates?.getComponentIndex === 'function') { + return globalOptions.customTemplates.getComponentIndex(globalOptions, file); + } + + if (globalOptions['root-ref'] === '-' || globalOptions['root-ref'] === '') { return '{{ autoComponentIndexer() }}\n'; } - return `{{ autoComponentIndexer('${rootRef || 'Model'}') }}\n`; + return `{{ autoComponentIndexer('${globalOptions['root-ref'] || 'Model'}') }}\n`; }; -export const getModel = (globalOptions: GlobalOptions): string => { +export const getModel = (globalOptions: GlobalOptions, file: string): string => { + if (typeof globalOptions.customTemplates?.getModel === 'function') { + return globalOptions.customTemplates.getModel(globalOptions, file); + } + return toYaml( { type: 'object', @@ -30,7 +38,11 @@ export const getModel = (globalOptions: GlobalOptions): string => { ); }; -export const getModels = (globalOptions: GlobalOptions, paginationRef: string = '../pagination/model.yml'): string => { +export const getModels = (globalOptions: GlobalOptions, file: string, paginationRef: string = '../pagination/model.yml'): string => { + if (typeof globalOptions.customTemplates?.getModels === 'function') { + return globalOptions.customTemplates.getModels(globalOptions, file, paginationRef); + } + return toYaml( { type: 'object', @@ -48,7 +60,17 @@ export const getModels = (globalOptions: GlobalOptions, paginationRef: string = ); }; -export const getParam = (globalOptions: GlobalOptions, name: string, paramIn: 'header' | 'path' | 'query', type: string = 'string'): string => { +export const getParam = ( + globalOptions: GlobalOptions, + file: string, + name: string, + paramIn: 'header' | 'path' | 'query', + type: string = 'string', +): string => { + if (typeof globalOptions.customTemplates?.getParam === 'function') { + return globalOptions.customTemplates.getParam(globalOptions, file, name, paramIn, type); + } + return toYaml( { in: paramIn, @@ -62,7 +84,11 @@ export const getParam = (globalOptions: GlobalOptions, name: string, paramIn: 'h ); }; -export const getPaginationModel = (globalOptions: GlobalOptions): string => { +export const getPaginationModel = (globalOptions: GlobalOptions, file: string): string => { + if (typeof globalOptions.customTemplates?.getPaginationModel === 'function') { + return globalOptions.customTemplates.getPaginationModel(globalOptions, file); + } + return toYaml( { type: 'object', diff --git a/src/templates/path.ts b/src/templates/path.ts index d45335a..a00630f 100644 --- a/src/templates/path.ts +++ b/src/templates/path.ts @@ -1,9 +1,25 @@ import { GlobalOptions } from '../cli'; import { capitalize, dashCase, toYaml } from '../lib'; -export const getPathIndex = (): string => '{{ autoPathIndexer() }}\n'; +export const getPathIndex = (globalOptions: GlobalOptions, file: string): string => { + if (typeof globalOptions.customTemplates?.getPathIndex === 'function') { + return globalOptions.customTemplates.getPathIndex(globalOptions, file); + } + + return '{{ autoPathIndexer() }}\n'; +}; + +export const getList = ( + globalOptions: GlobalOptions, + file: string, + pluralName: string, + schemaRef: string, + parameters?: { $ref: string }[], +): string => { + if (typeof globalOptions.customTemplates?.getList === 'function') { + return globalOptions.customTemplates.getList(globalOptions, file, pluralName, schemaRef, parameters); + } -export const getList = (globalOptions: GlobalOptions, pluralName: string, schemaRef: string, parameters?: { $ref: string }[]): string => { const spaceName = dashCase(pluralName).replace(/-/g, ' '); return toYaml( @@ -29,11 +45,16 @@ export const getList = (globalOptions: GlobalOptions, pluralName: string, schema export const getCreate = ( globalOptions: GlobalOptions, + file: string, singularName: string, requestSchemaRef: string, responseSchemaRef: string, parameters?: { $ref: string }[], ): string => { + if (typeof globalOptions.customTemplates?.getCreate === 'function') { + return globalOptions.customTemplates.getCreate(globalOptions, file, singularName, requestSchemaRef, responseSchemaRef, parameters); + } + const spaceName = dashCase(singularName).replace(/-/g, ' '); return toYaml( @@ -67,7 +88,17 @@ export const getCreate = ( ); }; -export const getShow = (globalOptions: GlobalOptions, singularName: string, responseSchemaRef: string, parameters?: { $ref: string }[]): string => { +export const getShow = ( + globalOptions: GlobalOptions, + file: string, + singularName: string, + responseSchemaRef: string, + parameters?: { $ref: string }[], +): string => { + if (typeof globalOptions.customTemplates?.getShow === 'function') { + return globalOptions.customTemplates.getShow(globalOptions, file, singularName, responseSchemaRef, parameters); + } + const spaceName = dashCase(singularName).replace(/-/g, ' '); return toYaml( @@ -93,7 +124,11 @@ export const getShow = (globalOptions: GlobalOptions, singularName: string, resp ); }; -export const getDelete = (globalOptions: GlobalOptions, singularName: string, parameters?: { $ref: string }[]): string => { +export const getDelete = (globalOptions: GlobalOptions, file: string, singularName: string, parameters?: { $ref: string }[]): string => { + if (typeof globalOptions.customTemplates?.getDelete === 'function') { + return globalOptions.customTemplates.getDelete(globalOptions, file, singularName, parameters); + } + const spaceName = dashCase(singularName).replace(/-/g, ' '); return toYaml( @@ -116,11 +151,16 @@ export const getDelete = (globalOptions: GlobalOptions, singularName: string, pa export const getUpdate = ( globalOptions: GlobalOptions, + file: string, singularName: string, requestSchemaRef: string, responseSchemaRef: string, parameters?: { $ref: string }[], ): string => { + if (typeof globalOptions.customTemplates?.getUpdate === 'function') { + return globalOptions.customTemplates.getUpdate(globalOptions, file, singularName, requestSchemaRef, responseSchemaRef, parameters); + } + const spaceName = dashCase(singularName).replace(/-/g, ' '); return toYaml( @@ -159,11 +199,16 @@ export const getUpdate = ( export const getReplace = ( globalOptions: GlobalOptions, + file: string, singularName: string, requestSchemaRef: string, responseSchemaRef: string, parameters?: { $ref: string }[], ): string => { + if (typeof globalOptions.customTemplates?.getReplace === 'function') { + return globalOptions.customTemplates.getReplace(globalOptions, file, singularName, requestSchemaRef, responseSchemaRef, parameters); + } + const spaceName = dashCase(singularName).replace(/-/g, ' '); return toYaml( diff --git a/test/boats.ts b/test/boats.ts index 0c493c5..658fcbf 100644 --- a/test/boats.ts +++ b/test/boats.ts @@ -14,7 +14,7 @@ type ConsoleMock = { unmock: () => void; }; -const mockConsoleLog = (): ConsoleMock => { +export const mockConsoleLog = (): ConsoleMock => { /* eslint-disable no-console */ const olog = console.log; const oerror = console.error; @@ -68,7 +68,7 @@ const overridePackageJsonReader = (): void => { }; }; -const defaultBoatsRc = JSON.parse(getBoatsRc()) as BoatsRC; +const defaultBoatsRc = JSON.parse(getBoatsRc({}, '')) as BoatsRC; export const boats = async (inFile: string, outFile: string, validate = true): Promise => { const trim = dirname(inFile) + '/paths/'; diff --git a/test/custom-models.spec.ts b/test/custom-models.spec.ts new file mode 100644 index 0000000..a0d4d74 --- /dev/null +++ b/test/custom-models.spec.ts @@ -0,0 +1,185 @@ +import assert from 'node:assert'; +import { rm } from 'node:fs/promises'; +import { beforeEach, describe, it } from 'node:test'; +import { cli, CliExit, GlobalOptions } from '../src/cli'; +import { generate } from '../src/generate'; +import { getBoatsRc, getIndex } from '../src/templates/init'; +import { getModel, getModels } from '../src/templates/model'; +import { boats, mockConsoleLog } from './boats'; +import { getAllFiles, getFile, toArgv } from './shared'; + +describe('custom-models.spec.ts', async () => { + beforeEach(async () => { + await rm('test/output/custom', { recursive: true, force: true }); + }); + + await it('can load custom models', async () => { + await cli( + toArgv(` + path auth/verify --get + path auth/login --post + path auth/refresh-token --get + path albums/:albumId --list --get + path albums/:albumId/songs/:songId -crudl + model jwt + model artist + --quiet + --output test/output/custom + --templates test/fixtures/overrides + `), + ); + + const indexFile = await boats('test/output/custom/src/index.yml', 'test/output/custom/api.json'); + + assert.strictEqual(indexFile !== '', true, 'boats failed'); + assert.strictEqual(await getFile(indexFile), await getFile('test/fixtures/spec/custom.json'), 'spec mismatch'); + }); + + await it('can disable custom model output by returning a falsey value', async () => { + const globalOptions: GlobalOptions = { + quiet: true, + output: 'test/output/custom', + customTemplates: { + getModel: (): string => '', + getModels: (): string => '', + }, + }; + + const files = await generate( + [ + { + contents: (): string => getModels(globalOptions, `src/components/schemas/something/models.yml`, '#/components/schemas/Pagination'), + filename: `src/components/schemas/something/models.yml`, + }, + { + contents: (): string => getModel(globalOptions, 'src/components/schemas/something/model.yml'), + filename: 'src/components/schemas/something/model.yml', + }, + { contents: (): string => getIndex(globalOptions, 'src/index.yml'), filename: 'src/index.yml' }, + { contents: (): string => getBoatsRc(globalOptions, '.boatsrc'), filename: '.boatsrc' }, + ], + globalOptions, + ); + + assert.deepStrictEqual( + JSON.stringify(Object.entries(files).sort(([a], [b]) => b.localeCompare(a))), + JSON.stringify([ + ['test/output/custom/src/index.yml', { filename: 'test/output/custom/src/index.yml' }], + [ + 'test/output/custom/src/components/schemas/something/models.yml', + { filename: 'test/output/custom/src/components/schemas/something/models.yml', skipped: true }, + ], + [ + 'test/output/custom/src/components/schemas/something/model.yml', + { filename: 'test/output/custom/src/components/schemas/something/model.yml', skipped: true }, + ], + ['test/output/custom/src/components/schemas/index.yml', { filename: 'test/output/custom/src/components/schemas/index.yml' }], + ['test/output/custom/.boatsrc', { filename: 'test/output/custom/.boatsrc' }], + ]), + ); + + assert.deepStrictEqual(await getAllFiles('test/output/custom/'), [ + 'test/output/custom/.boatsrc', + 'test/output/custom/src/components/schemas/index.yml', + 'test/output/custom/src/index.yml', + ]); + }); + + await it('can load all overrides from a module', async () => { + assert.deepStrictEqual(await getAllFiles('test/output/custom/').catch(() => []), []); + + await cli( + toArgv(` + path users/:userId -crudl --put + model jwt + model userId --type query + --quiet + --output test/output/custom + -T test/fixtures/overrides/module.js + `), + ); + + const files = await getAllFiles('test/output/custom/'); + assert.deepStrictEqual(files, [ + 'test/output/custom/.boatsrc', + 'test/output/custom/src/components/parameters/index.yml', + 'test/output/custom/src/components/parameters/pathUserId.yml', + 'test/output/custom/src/components/parameters/queryLimit.yml', + 'test/output/custom/src/components/parameters/queryOffset.yml', + 'test/output/custom/src/components/parameters/queryUserId.yml', + 'test/output/custom/src/components/schemas/index.yml', + 'test/output/custom/src/components/schemas/jwt/model.yml', + 'test/output/custom/src/components/schemas/pagination/model.yml', + 'test/output/custom/src/components/schemas/user/model.yml', + 'test/output/custom/src/components/schemas/user/models.yml', + 'test/output/custom/src/components/schemas/user/patch.yml', + 'test/output/custom/src/components/schemas/user/post.yml', + 'test/output/custom/src/components/schemas/user/put.yml', + 'test/output/custom/src/index.yml', + 'test/output/custom/src/paths/index.yml', + 'test/output/custom/src/paths/users/get.yml', + 'test/output/custom/src/paths/users/post.yml', + 'test/output/custom/src/paths/users/{userId}/delete.yml', + 'test/output/custom/src/paths/users/{userId}/get.yml', + 'test/output/custom/src/paths/users/{userId}/patch.yml', + 'test/output/custom/src/paths/users/{userId}/put.yml', + ]); + + for (const file of await getAllFiles('test/output/custom/')) { + assert.deepStrictEqual(await getFile(file), file.replace('test/output/custom/', '')); + } + }); + + await it('outputs a meaningful error message', async () => { + let log = mockConsoleLog(); + let error = await cli( + toArgv(` + path users/:userId -crudl --put + model jwt + model userId --type query + --quiet + --output test/output/custom + -T something-that-doesnt-exist + `), + ).catch((e: unknown) => e); + log.unmock(); + + assert.strictEqual(error instanceof CliExit, true); + assert.deepStrictEqual(log.logs, [['cannot load templates "something-that-doesnt-exist": not a module\n'], log.logs[1]]); + + log = mockConsoleLog(); + error = await cli( + toArgv(` + path users/:userId -crudl --put + model jwt + model userId --type query + --quiet + --output test/output/custom + -T test/fixtures/spec + `), + ).catch((e: unknown) => e); + log.unmock(); + + assert.strictEqual(error instanceof CliExit, true); + assert.deepStrictEqual(log.logs, [['cannot load templates "test/fixtures/spec": template folder has no override files\n'], log.logs[1]]); + + log = mockConsoleLog(); + error = await cli( + toArgv(` + path users/:userId -crudl --put + model jwt + model userId --type query + --quiet + --output test/output/custom + -T test/fixtures/overrides/boats-rc.js + `), + ).catch((e: unknown) => e); + log.unmock(); + + assert.strictEqual(error instanceof CliExit, true); + assert.deepStrictEqual(log.logs, [ + ['cannot load templates "test/fixtures/overrides/boats-rc.js": module has no override exports\n'], + log.logs[1], + ]); + }); +}).catch(console.error); diff --git a/test/fixtures/overrides/boats-rc.js b/test/fixtures/overrides/boats-rc.js new file mode 100644 index 0000000..24757dd --- /dev/null +++ b/test/fixtures/overrides/boats-rc.js @@ -0,0 +1,9 @@ +// @ts-check + +/** + * @type{import('../../../').CustomTemplates['getBoatsRc']} + * @returns{string|null} + */ +module.exports = (_globalOptions, file) => { + return `{ fancyPluralization: true, filename: "${file}" }\n`; +}; diff --git a/test/fixtures/overrides/component-index.js b/test/fixtures/overrides/component-index.js new file mode 100644 index 0000000..13f26b8 --- /dev/null +++ b/test/fixtures/overrides/component-index.js @@ -0,0 +1,8 @@ +// @ts-check + +/** @type{import('../../../').CustomTemplates['getComponentIndex']} */ +module.exports = (globalOptions, file) => { + const remove = globalOptions['root-ref'] === '-' || globalOptions['root-ref'] === '' ? '' : `'${globalOptions['root-ref'] || 'Model'}'`; + + return `{{ autoComponentIndexer(${remove}) }}\n# ${JSON.stringify({ globalOptions, file })}\n`; +}; diff --git a/test/fixtures/overrides/create.js b/test/fixtures/overrides/create.js new file mode 100644 index 0000000..51f63b0 --- /dev/null +++ b/test/fixtures/overrides/create.js @@ -0,0 +1,11 @@ +// @ts-check + +const { toYaml } = require('../../../dist/src/lib'); + +/** @type{import('../../../').CustomTemplates['getCreate']} */ +module.exports = (_globalOptions, file) => { + return toYaml({ + summary: 'create', + description: `from ${file}`, + }); +}; diff --git a/test/fixtures/overrides/delete.js b/test/fixtures/overrides/delete.js new file mode 100644 index 0000000..b6b6547 --- /dev/null +++ b/test/fixtures/overrides/delete.js @@ -0,0 +1,11 @@ +// @ts-check + +const { toYaml } = require('../../../dist/src/lib'); + +/** @type{import('../../../').CustomTemplates['getDelete']} */ +module.exports = (_globalOptions, file) => { + return toYaml({ + summary: 'delete', + description: `from ${file}`, + }); +}; diff --git a/test/fixtures/overrides/index.js b/test/fixtures/overrides/index.js new file mode 100644 index 0000000..efd0559 --- /dev/null +++ b/test/fixtures/overrides/index.js @@ -0,0 +1,16 @@ +// @ts-check + +const { toYaml } = require('../../../dist/src/lib'); + +/** @type{import('../../../').CustomTemplates['getIndex']} */ +module.exports = (_globalOptions, file) => { + return toYaml({ + openapi: '3.1.0', + paths: { $ref: 'paths/index.yml' }, + info: { title: `from ${file}`, version: '1.0' }, + components: { + parameters: { $ref: 'components/parameters/index.yml' }, + schemas: { $ref: 'components/schemas/index.yml' }, + }, + }); +}; diff --git a/test/fixtures/overrides/list.js b/test/fixtures/overrides/list.js new file mode 100644 index 0000000..cdfeb24 --- /dev/null +++ b/test/fixtures/overrides/list.js @@ -0,0 +1,22 @@ +// @ts-check + +const { toYaml } = require('../../../dist/src/lib'); + +/** @type{import('../../../').CustomTemplates['getList']} */ +module.exports = (_globalOptions, file, pluralName, schemaRef, parameters) => { + return toYaml({ + summary: `from ${file}`, + description: `pluralName ${pluralName}`, + ...(parameters?.length ? { parameters } : {}), + responses: { + '"200"': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: schemaRef }, + }, + }, + }, + }, + }); +}; diff --git a/test/fixtures/overrides/model.js b/test/fixtures/overrides/model.js new file mode 100644 index 0000000..896877f --- /dev/null +++ b/test/fixtures/overrides/model.js @@ -0,0 +1,36 @@ +// @ts-check + +const { writeFile, mkdir } = require('node:fs/promises'); +const { toYaml } = require('../../../dist/src/lib'); +const { dirname, join } = require('node:path'); + +/** @type{import('../../../').CustomTemplates['getModel']} */ +module.exports = async (globalOptions, file) => { + const base = join(globalOptions.output || '.', file.replace('model.yml', 'base.yml')); + await mkdir(dirname(base), { recursive: true }); + await writeFile( + base, + toYaml({ + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + }, + }), + ); + + return toYaml({ + allOf: [ + { $ref: './base.yml' }, + { + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid', + }, + }, + }, + ], + }); +}; diff --git a/test/fixtures/overrides/module.js b/test/fixtures/overrides/module.js new file mode 100644 index 0000000..ffb7b79 --- /dev/null +++ b/test/fixtures/overrides/module.js @@ -0,0 +1,14 @@ +exports.getBoatsRc = (_opts, file) => file; +exports.getIndex = (_opts, file) => file; +exports.getComponentIndex = (_opts, file) => file; +exports.getModel = (_opts, file) => file; +exports.getModels = (_opts, file) => file; +exports.getParam = (_opts, file) => file; +exports.getPaginationModel = (_opts, file) => file; +exports.getPathIndex = (_opts, file) => file; +exports.getList = (_opts, file) => file; +exports.getCreate = (_opts, file) => file; +exports.getShow = (_opts, file) => file; +exports.getDelete = (_opts, file) => file; +exports.getUpdate = (_opts, file) => file; +exports.getReplace = (_opts, file) => file; diff --git a/test/fixtures/overrides/pagination-model.js b/test/fixtures/overrides/pagination-model.js new file mode 100644 index 0000000..baf17a5 --- /dev/null +++ b/test/fixtures/overrides/pagination-model.js @@ -0,0 +1,10 @@ +// @ts-check + +const { getPaginationModel } = require('../../../dist/src/templates/model'); + +/** @type{import('../../../').CustomTemplates['getPaginationModel']} */ +module.exports = (globalOptions, file) => { + const original = getPaginationModel({ 'no-quote': globalOptions['no-quote'] }, file); + + return original + '\n# more on this later'; +}; diff --git a/test/fixtures/overrides/param.js b/test/fixtures/overrides/param.js new file mode 100644 index 0000000..4cc2baf --- /dev/null +++ b/test/fixtures/overrides/param.js @@ -0,0 +1,15 @@ +// @ts-check + +const { toYaml } = require('../../../dist/src/lib'); + +/** @type{import('../../../').CustomTemplates['getParam']} */ +module.exports = (_globalOptions, file, name, paramIn, type = 'string') => { + return toYaml({ + in: paramIn, + name, + required: paramIn === 'path', + schema: { type }, + description: `${paramIn} param that does some stuff`, + example: `dont pass in a file, like this: ${file}`, + }); +}; diff --git a/test/fixtures/overrides/path-index.js b/test/fixtures/overrides/path-index.js new file mode 100644 index 0000000..faf10a5 --- /dev/null +++ b/test/fixtures/overrides/path-index.js @@ -0,0 +1,4 @@ +// @ts-check + +/** @type{import('../../../').CustomTemplates['getPathIndex']} */ +module.exports = (_globalOptions, file) => `{{ autoPathIndexer() }}\n# ${file}\n`; diff --git a/test/fixtures/overrides/replace.js b/test/fixtures/overrides/replace.js new file mode 100644 index 0000000..ba0a220 --- /dev/null +++ b/test/fixtures/overrides/replace.js @@ -0,0 +1,10 @@ +// @ts-check + +const { toYaml } = require('../../../dist/src/lib'); + +/** @type{import('../../../').CustomTemplates['getReplace']} */ +module.exports = (_globalOptions, file, singularName) => + toYaml({ + summary: `upsert ${singularName}`, + description: `from ${file}`, + }); diff --git a/test/fixtures/overrides/show.js b/test/fixtures/overrides/show.js new file mode 100644 index 0000000..62a1047 --- /dev/null +++ b/test/fixtures/overrides/show.js @@ -0,0 +1,23 @@ +// @ts-check + +const { toYaml } = require('../../../dist/src/lib'); + +/** @type{import('../../../').CustomTemplates['getShow']} */ +module.exports = (_globalOptions, file) => { + return toYaml({ + summary: `Show something, from ${file}`, + responses: { + '"404"': { + description: 'not found', + }, + '"200"': { + description: 'Success', + content: { + 'text/plain': { + schema: { type: 'string' }, + }, + }, + }, + }, + }); +}; diff --git a/test/fixtures/overrides/update.js b/test/fixtures/overrides/update.js new file mode 100644 index 0000000..9b0a14e --- /dev/null +++ b/test/fixtures/overrides/update.js @@ -0,0 +1,38 @@ +// @ts-check + +const { toYaml, capitalize, dashCase } = require('../../../dist/src/lib'); + +/** @type{import('../../../').CustomTemplates['getUpdate']} */ +module.exports = (_globalOptions, file, singularName, requestSchemaRef, responseSchemaRef, parameters) => { + const spaceName = dashCase(singularName).replace(/-/g, ' '); + + return toYaml({ + summary: `Update parts of a ${singularName}`, + description: `from ${file}`, + ...(parameters?.length ? { parameters } : {}), + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: requestSchemaRef }, + }, + }, + }, + responses: { + '"404"': { + description: `${capitalize(spaceName)} not found`, + }, + '"422"': { + description: `Invalid ${spaceName} supplied`, + }, + '"200"': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: responseSchemaRef }, + }, + }, + }, + }, + }); +}; diff --git a/test/fixtures/spec/custom.json b/test/fixtures/spec/custom.json new file mode 100644 index 0000000..4b9cbfe --- /dev/null +++ b/test/fixtures/spec/custom.json @@ -0,0 +1,513 @@ +{ + "openapi": "3.1.0", + "paths": { + "/albums": { + "get": { + "summary": "from src/paths/albums/get.yml", + "description": "pluralName albums", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Albums" + } + } + } + } + } + } + }, + "/albums/{albumId}": { + "get": { + "summary": "Show something, from src/paths/albums/{albumId}/get.yml", + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "not found" + } + } + } + }, + "/albums/{albumId}/songs": { + "get": { + "summary": "from src/paths/albums/{albumId}/songs/get.yml", + "description": "pluralName songs", + "parameters": [ + { + "$ref": "#/components/parameters/PathAlbumId" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Songs" + } + } + } + } + } + }, + "post": { + "summary": "create", + "description": "from src/paths/albums/{albumId}/songs/post.yml" + } + }, + "/albums/{albumId}/songs/{songId}": { + "delete": { + "summary": "delete", + "description": "from src/paths/albums/{albumId}/songs/{songId}/delete.yml" + }, + "get": { + "summary": "Show something, from src/paths/albums/{albumId}/songs/{songId}/get.yml", + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "not found" + } + } + }, + "patch": { + "summary": "Update parts of a song", + "description": "from src/paths/albums/{albumId}/songs/{songId}/patch.yml", + "parameters": [ + { + "$ref": "#/components/parameters/PathAlbumId" + }, + { + "$ref": "#/components/parameters/PathSongId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SongPatch" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Song" + } + } + } + }, + "404": { + "description": "Song not found" + }, + "422": { + "description": "Invalid song supplied" + } + } + } + }, + "/auth/login": { + "post": { + "summary": "create", + "description": "from src/paths/auth/login/post.yml" + } + }, + "/auth/refresh-token": { + "get": { + "summary": "Show something, from src/paths/auth/refresh-token/get.yml", + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "not found" + } + } + } + }, + "/auth/verify": { + "get": { + "summary": "Show something, from src/paths/auth/verify/get.yml", + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "not found" + } + } + } + } + }, + "info": { + "title": "from src/index.yml", + "version": "1.0" + }, + "components": { + "parameters": { + "PathAlbumId": { + "in": "path", + "name": "albumId", + "required": true, + "schema": { + "type": "string" + }, + "description": "path param that does some stuff", + "example": "dont pass in a file, like this: src/components/parameters/pathAlbumId.yml" + }, + "PathSongId": { + "in": "path", + "name": "songId", + "required": true, + "schema": { + "type": "string" + }, + "description": "path param that does some stuff", + "example": "dont pass in a file, like this: src/components/parameters/pathSongId.yml" + }, + "QueryLimit": { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + }, + "description": "query param that does some stuff", + "example": "dont pass in a file, like this: src/components/parameters/queryLimit.yml" + }, + "QueryOffset": { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "type": "integer" + }, + "description": "query param that does some stuff", + "example": "dont pass in a file, like this: src/components/parameters/queryOffset.yml" + } + }, + "schemas": { + "AlbumBase": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "Album": { + "allOf": [ + { + "$ref": "#/components/schemas/AlbumBase" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + } + } + ] + }, + "Albums": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/Pagination" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Album" + } + } + } + }, + "ArtistBase": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "Artist": { + "allOf": [ + { + "$ref": "#/components/schemas/ArtistBase" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + } + } + ] + }, + "JwtBase": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "Jwt": { + "allOf": [ + { + "$ref": "#/components/schemas/JwtBase" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + } + } + ] + }, + "LoginBase": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "Login": { + "allOf": [ + { + "$ref": "#/components/schemas/LoginBase" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + } + } + ] + }, + "LoginPost": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "Pagination": { + "type": "object", + "required": [ + "offset", + "limit", + "total" + ], + "properties": { + "offset": { + "type": "integer", + "minimum": 0, + "description": "Starting index" + }, + "limit": { + "type": "integer", + "minimum": 0, + "description": "Max items returned" + }, + "total": { + "type": "integer", + "minimum": 0, + "description": "Total items available" + } + } + }, + "RefreshTokenBase": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "RefreshToken": { + "allOf": [ + { + "$ref": "#/components/schemas/RefreshTokenBase" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + } + } + ] + }, + "SongBase": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "Song": { + "allOf": [ + { + "$ref": "#/components/schemas/SongBase" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + } + } + ] + }, + "Songs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/Pagination" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Song" + } + } + } + }, + "SongPatch": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "SongPost": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "VerifyBase": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "Verify": { + "allOf": [ + { + "$ref": "#/components/schemas/VerifyBase" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + } + } + ] + } + } + } +} \ No newline at end of file diff --git a/test/path.spec.ts b/test/path.spec.ts index 45514b6..7a31312 100644 --- a/test/path.spec.ts +++ b/test/path.spec.ts @@ -378,7 +378,7 @@ describe('path.spec.ts', async () => { assert.deepStrictEqual(await getAllFiles('test/output/path'), outputFiles, 'files mismatch'); - assert.equal(await getFile('test/output/path/.boatsrc'), getBoatsRc()); + assert.equal(await getFile('test/output/path/.boatsrc'), getBoatsRc({}, '')); assert.equal(await getFile('test/output/path/src/components/parameters/index.yml'), '{{ autoComponentIndexer() }}\n'); @@ -479,7 +479,7 @@ describe('path.spec.ts', async () => { assert.equal(await getFile('test/output/path/src/components/schemas/photo/post.yml'), baseModel); - assert.equal(await getFile('test/output/path/src/index.yml'), getIndex({})); + assert.equal(await getFile('test/output/path/src/index.yml'), getIndex({}, '')); assert.equal(await getFile('test/output/path/src/paths/index.yml'), '{{ autoPathIndexer() }}\n'); diff --git a/test/shared.ts b/test/shared.ts index ca93171..f603e31 100644 --- a/test/shared.ts +++ b/test/shared.ts @@ -17,7 +17,7 @@ export const getAllFiles = (path: string): Promise => return files.sort(); }); -export const baseModel = getModel({}); +export const baseModel = getModel({}, ''); export const getFile = (file: string): Promise => readFile(file, { encoding: 'utf8' }); export const getLogger = (): (() => void) => {