From b86fdfa6777bf1e1e4313fc61ea5d4c9d98d75f6 Mon Sep 17 00:00:00 2001 From: p-mcgowan Date: Mon, 28 Apr 2025 15:43:05 -0700 Subject: [PATCH] feat: output without quoted strings closes #21 - added -Q, --no-quote to output string values without quotes. NOTE: this will not be guaranteed to produce valid YAML, as the string quoting requirements are non-trivial and out of scope for the feature. See https://stackoverflow.com/a/22235064 (and the comments) about why quoting by default is preferred. --- src/cli.ts | 9 +- src/lib.ts | 16 +- src/subcommands/model.ts | 28 +-- src/subcommands/path.ts | 81 ++++--- src/templates/init.ts | 37 +-- src/templates/model.ts | 97 ++++---- src/templates/path.ts | 253 +++++++++++--------- test/e2e.spec.ts | 483 +++++++++++++++++++++++++++++++++++++++ test/path.spec.ts | 2 +- test/shared.ts | 2 +- 10 files changed, 789 insertions(+), 219 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index cfa224e..98caf36 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ export type GlobalOptions = { force?: boolean; 'no-index'?: boolean; 'no-init'?: boolean; + 'no-quote'?: boolean; output?: string; quiet?: boolean; verbose?: boolean; @@ -49,6 +50,12 @@ export const cliArguments: Record = { 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' }, + 'no-quote': { + type: 'boolean', + short: 'Q', + [description]: + 'Remove quotes around string values. Note that this is not guaranteed to output a valid value as YAML string rules are non-trivial, so use at your own discretion.', + }, output: { type: 'string', short: 'o', [argname]: 'OUTPUT_DIR', [description]: 'Location to output files to (defaults to current folder)' }, quiet: { type: 'boolean', short: 'q', [description]: 'Only output errors' }, verbose: { type: 'boolean', short: 'v', [description]: 'Print the contents of the files to be generated' }, @@ -260,7 +267,7 @@ export const cli = async (args: string[]): Promise getIndex(globalOptions), filename: 'src/index.yml' }, { contents: getBoatsRc, filename: '.boatsrc' }); } if (!tasks.length) { diff --git a/src/lib.ts b/src/lib.ts index 95f8015..9736923 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -54,9 +54,15 @@ export const createFile = async (path: string, content: string, force = false): return true; }; -export const toYaml = (object: Json, trailingNewline = true): string => { +/** + * YAML string rules are non-trivial - only set quoteProps=false when you know + * there is no chance of invalid unquoted strings. + * + * see https://stackoverflow.com/a/22235064 and the comments with it. + */ +export const toYaml = (object: Json, trailingNewline = true, quoteProps = true): string => { const out: string[] = []; - toYamlRecurse(object, out, 0); + toYamlRecurse(object, out, 0, Array.isArray(object), quoteProps); if (trailingNewline) { out.push(''); @@ -65,7 +71,7 @@ export const toYaml = (object: Json, trailingNewline = true): string => { return out.join('\n'); }; -const toYamlRecurse = (object: Json | Json[string], out: string[], depth = 0, isArray = false): void => { +const toYamlRecurse = (object: Json | Json[string], out: string[], depth = 0, isArray = false, quoteProps = true): void => { const prevIndent = depth ? ' '.repeat((depth - 1) * 2) : ''; if (typeof object === 'object' && object !== null) { @@ -78,13 +84,13 @@ const toYamlRecurse = (object: Json | Json[string], out: string[], depth = 0, is } else { out.push(`${' '.repeat(depth * 2)}${key}:`); } - toYamlRecurse((object as Json)[key], out, depth + 1, Array.isArray(object)); + toYamlRecurse((object as Json)[key], out, depth + 1, Array.isArray(object), quoteProps); } return; } if (typeof object === 'string') { - object = `"${object.replace(/\"/g, '\\"')}"`; + object = quoteProps ? `"${object.replace(/\"/g, '\\"')}"` : object; } if (isArray) { diff --git a/src/subcommands/model.ts b/src/subcommands/model.ts index b12c6b9..61262d3 100644 --- a/src/subcommands/model.ts +++ b/src/subcommands/model.ts @@ -5,6 +5,7 @@ import { getModel, getModels, getPaginationModel, getParam } from '../templates/ type ModelGenerationOptions = { name: string; + globalOptions: GlobalOptions; 'no-model'?: boolean; type?: 'query' | 'header' | 'path'; list?: boolean; @@ -14,7 +15,6 @@ type ModelGenerationOptions = { patch?: boolean; post?: boolean; put?: boolean; - rootRef?: string; }; export const modelCliArguments: Record = { @@ -105,7 +105,7 @@ export const parseModelCommand: SubcommandGenerator = (args: string[], globalOpt return help(1, 'Error: --type argument must be query, header, or path'); } - return getModelTasks({ ...parsed.values, rootRef: globalOptions['root-ref'], name }); + return getModelTasks({ globalOptions, ...parsed.values, name }); }; export const getModelTasks = (options: ModelGenerationOptions): GenerationTask[] => { @@ -114,41 +114,41 @@ export const getModelTasks = (options: ModelGenerationOptions): GenerationTask[] const tasks: GenerationTask[] = []; tasks.push({ - contents: getModel, + contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-model'] && !options.type, }); - const paginationRef = getRootRef('../pagination/model.yml', '#/components/schemas/PaginationModel', options.rootRef); + const paginationRef = getRootRef('../pagination/model.yml', '#/components/schemas/PaginationModel', options.globalOptions['root-ref']); if (options.type) { tasks.push({ - contents: () => getParam(options.name, options.type as Exclude<(typeof options)['type'], undefined>), + contents: () => getParam(options.globalOptions, options.name, options.type as Exclude<(typeof options)['type'], undefined>), filename: `src/components/parameters/${options.type}${capitalize(camelName)}.yml`, }); } if (options.list || options.crud) { tasks.push( - { contents: () => getModels(paginationRef), filename: `src/components/schemas/${dashName}/models.yml` }, - { contents: () => getParam('limit', 'query', 'integer'), filename: `src/components/parameters/queryLimit.yml` }, - { contents: () => getParam('offset', 'query', 'integer'), filename: `src/components/parameters/queryOffset.yml` }, - { contents: () => getPaginationModel(), filename: `src/components/schemas/pagination/model.yml` }, + { 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` }, ); } if (options.get || options.crud) { - tasks.push({ contents: getModel, filename: `src/components/schemas/${dashName}/get.yml` }); + tasks.push({ contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/get.yml` }); } if (options.delete || options.crud) { - tasks.push({ contents: getModel, filename: `src/components/schemas/${dashName}/delete.yml` }); + tasks.push({ contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/delete.yml` }); } if (options.patch || options.crud) { - tasks.push({ contents: getModel, filename: `src/components/schemas/${dashName}/patch.yml` }); + tasks.push({ contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/patch.yml` }); } if (options.post || options.crud) { - tasks.push({ contents: getModel, filename: `src/components/schemas/${dashName}/post.yml` }); + tasks.push({ contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/post.yml` }); } if (options.put) { - tasks.push({ contents: getModel, filename: `src/components/schemas/${dashName}/put.yml` }); + tasks.push({ contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/put.yml` }); } return tasks; diff --git a/src/subcommands/path.ts b/src/subcommands/path.ts index 745bcc4..edce60c 100644 --- a/src/subcommands/path.ts +++ b/src/subcommands/path.ts @@ -7,6 +7,7 @@ import { getCreate, getDelete, getList, getReplace, getShow, getUpdate } from '. type PathGenerationOptions = { name: string; + globalOptions: GlobalOptions; 'no-models'?: boolean; model?: string; type?: string; @@ -16,7 +17,6 @@ type PathGenerationOptions = { patch?: boolean; post?: boolean; put?: boolean; - rootRef?: string; }; export const pathCliArguments: Record = { @@ -127,7 +127,7 @@ export const parsePathCommand: SubcommandGenerator = (args: string[], globalOpti return help(1, `invalid path arg "${name}"`); } - const tasks = getPathTasks({ ...parsed.values, rootRef: globalOptions['root-ref'], name }); + const tasks = getPathTasks({ ...parsed.values, globalOptions, name }); if (!tasks.length) { return help(1, 'Error: Nothing to do. Aborting. Did you forget crud options (eg --get)?'); @@ -140,6 +140,7 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = const pathParams: { rootRef: string; srcRef: string }[] = []; let lastIsParam = false; const tasks: GenerationTask[] = []; + const rootRef = options.globalOptions['root-ref']; const parts = options.name.split('/').filter(Boolean); for (let i = 0; i < parts.length; ++i) { @@ -152,7 +153,7 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = if (pathParam) { lastIsParam = true; tasks.push({ - contents: () => getParam(pathParam, 'path'), + contents: () => getParam(options.globalOptions, pathParam, 'path'), filename: `src/components/parameters/path${capitalize(pathParam)}.yml`, generate: !options['no-models'], }); @@ -181,32 +182,40 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = const dashName = dashCase(singleModelName); const singularName = camelCase(singleModelName); - const paginationRef = getRootRef('../pagination/model.yml', '#/components/schemas/PaginationModel', options.rootRef); + const paginationRef = getRootRef('../pagination/model.yml', '#/components/schemas/PaginationModel', rootRef); if (options.list) { const filename = `src/paths/${normalizedBaseFilepath}/get.yml`; const listSchemaRef = getRootRef( relative(`src/paths/${normalizedBaseFilepath}`, `src/components/schemas/${dashName}/models.yml`), `#/components/schemas/${capitalize(singleModelName)}Models`, - options.rootRef, + rootRef, ); - const paramRefs = mapParamRefs(otherPathParams, dirname(filename), options.rootRef); + const paramRefs = mapParamRefs(otherPathParams, dirname(filename), rootRef); tasks.push( { - contents: () => getParam('limit', 'query', 'integer'), + contents: () => getParam(options.globalOptions, 'limit', 'query', 'integer'), filename: `src/components/parameters/queryLimit.yml`, generate: !options['no-models'], }, { - contents: () => getParam('offset', 'query', 'integer'), + contents: () => getParam(options.globalOptions, 'offset', 'query', 'integer'), filename: 'src/components/parameters/queryOffset.yml', generate: !options['no-models'], }, - { contents: getPaginationModel, filename: 'src/components/schemas/pagination/model.yml', generate: !options['no-models'] }, - { contents: getModel, filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-models'] }, - { contents: () => getModels(paginationRef), filename: `src/components/schemas/${dashName}/models.yml`, generate: !options['no-models'] }, - { contents: () => getList(customModelName, listSchemaRef, paramRefs), filename }, + { + contents: () => getPaginationModel(options.globalOptions), + 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), + filename: `src/components/schemas/${dashName}/models.yml`, + generate: !options['no-models'], + }, + { contents: () => getList(options.globalOptions, customModelName, listSchemaRef, paramRefs), filename }, ); } @@ -215,19 +224,19 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = const postRequestRef = getRootRef( relative(dirname(filename), `src/components/schemas/${dashName}/post.yml`), `#/components/schemas/${capitalize(singularName)}Post`, - options.rootRef, + rootRef, ); const postResponseRef = getRootRef( relative(dirname(filename), `src/components/schemas/${dashName}/model.yml`), `#/components/schemas/${capitalize(singularName)}Model`, - options.rootRef, + rootRef, ); - const paramRefs = mapParamRefs(otherPathParams, dirname(filename), options.rootRef); + const paramRefs = mapParamRefs(otherPathParams, dirname(filename), rootRef); tasks.push( - { contents: getModel, filename: `src/components/schemas/${dashName}/post.yml`, generate: !options['no-models'] }, - { contents: getModel, filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-models'] }, - { contents: () => getCreate(singularName, postRequestRef, postResponseRef, paramRefs), filename }, + { 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 }, ); } @@ -236,21 +245,21 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = const postResponseRef = getRootRef( relative(dirname(filename), `src/components/schemas/${dashName}/model.yml`), `#/components/schemas/${capitalize(singularName)}Model`, - options.rootRef, + rootRef, ); - const paramRefs = mapParamRefs(pathParams, dirname(filename), options.rootRef); + const paramRefs = mapParamRefs(pathParams, dirname(filename), rootRef); tasks.push( - { contents: getModel, filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-models'] }, - { contents: () => getShow(singularName, postResponseRef, paramRefs), filename }, + { contents: () => getModel(options.globalOptions), filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-models'] }, + { contents: () => getShow(options.globalOptions, singularName, postResponseRef, paramRefs), filename }, ); } if (options.delete) { const filename = `src/paths/${normalizedFilepath}/delete.yml`; - const paramRefs = mapParamRefs(pathParams, dirname(filename), options.rootRef); + const paramRefs = mapParamRefs(pathParams, dirname(filename), rootRef); - tasks.push({ contents: () => getDelete(singularName, paramRefs), filename }); + tasks.push({ contents: () => getDelete(options.globalOptions, singularName, paramRefs), filename }); } if (options.patch) { @@ -258,19 +267,19 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = const postRequestRef = getRootRef( relative(dirname(filename), `src/components/schemas/${dashName}/patch.yml`), `#/components/schemas/${capitalize(singularName)}Patch`, - options.rootRef, + rootRef, ); const postResponseRef = getRootRef( relative(dirname(filename), `src/components/schemas/${dashName}/model.yml`), `#/components/schemas/${capitalize(singularName)}Model`, - options.rootRef, + rootRef, ); - const paramRefs = mapParamRefs(pathParams, dirname(filename), options.rootRef); + const paramRefs = mapParamRefs(pathParams, dirname(filename), rootRef); tasks.push( - { contents: getModel, filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-models'] }, - { contents: getModel, filename: `src/components/schemas/${dashName}/patch.yml`, generate: !options['no-models'] }, - { contents: () => getUpdate(singularName, postRequestRef, postResponseRef, paramRefs), filename }, + { 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 }, ); } @@ -279,19 +288,19 @@ export const getPathTasks = (options: PathGenerationOptions): GenerationTask[] = const postRequestRef = getRootRef( relative(dirname(filename), `src/components/schemas/${dashName}/put.yml`), `#/components/schemas/${capitalize(singularName)}Put`, - options.rootRef, + rootRef, ); const postResponseRef = getRootRef( relative(dirname(filename), `src/components/schemas/${dashName}/model.yml`), `#/components/schemas/${capitalize(singularName)}Model`, - options.rootRef, + rootRef, ); - const paramRefs = mapParamRefs(pathParams, dirname(filename), options.rootRef); + const paramRefs = mapParamRefs(pathParams, dirname(filename), rootRef); tasks.push( - { contents: getModel, filename: `src/components/schemas/${dashName}/model.yml`, generate: !options['no-models'] }, - { contents: getModel, filename: `src/components/schemas/${dashName}/put.yml`, generate: !options['no-models'] }, - { contents: () => getReplace(singularName, postRequestRef, postResponseRef, paramRefs), filename }, + { 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 }, ); } diff --git a/src/templates/init.ts b/src/templates/init.ts index 2860bea..644335d 100644 --- a/src/templates/init.ts +++ b/src/templates/init.ts @@ -1,3 +1,4 @@ +import { GlobalOptions } from '../cli'; import { toYaml } from '../lib'; const autoTagOpid = ` @@ -15,23 +16,27 @@ const autoTagOpid = ` ]) }}`; -export const getIndex = (): string => { - const yaml = toYaml({ - openapi: '3.1.0', - info: { - version: "{{ packageJson('version') }}", - title: "{{ packageJson('name') }}", - description: 'our sweet api', - contact: { name: 'acrontum', email: 'support@acrontum.de' }, - license: { name: 'Apache 2.0', url: 'https://www.apache.org/licenses/LICENSE-2.0.html' }, - }, - servers: [{ url: '/v1' }], - paths: { $ref: 'paths/index.yml' }, - components: { - parameters: { $ref: 'components/parameters/index.yml' }, - schemas: { $ref: 'components/schemas/index.yml' }, +export const getIndex = (globalOptions: GlobalOptions): string => { + const yaml = toYaml( + { + openapi: '3.1.0', + info: { + version: "{{ packageJson('version') }}", + title: "{{ packageJson('name') }}", + description: 'our sweet api', + contact: { name: 'acrontum', email: 'support@acrontum.de' }, + license: { name: 'Apache 2.0', url: 'https://www.apache.org/licenses/LICENSE-2.0.html' }, + }, + servers: [{ url: '/v1' }], + paths: { $ref: 'paths/index.yml' }, + components: { + parameters: { $ref: 'components/parameters/index.yml' }, + schemas: { $ref: 'components/schemas/index.yml' }, + }, }, - }); + true, + !globalOptions['no-quote'], + ); return `${yaml}${autoTagOpid}\n`; }; diff --git a/src/templates/model.ts b/src/templates/model.ts index 080a84f..29d37cf 100644 --- a/src/templates/model.ts +++ b/src/templates/model.ts @@ -1,3 +1,4 @@ +import { GlobalOptions } from '../cli'; import { toYaml } from '../lib'; export const getComponentIndex = (rootRef?: string): string => { @@ -8,55 +9,71 @@ export const getComponentIndex = (rootRef?: string): string => { return `{{ autoComponentIndexer('${rootRef || 'Model'}') }}\n`; }; -export const getModel = (): string => { - return toYaml({ - type: 'object', - required: ['name'], - properties: { - name: { - type: 'string', - description: 'Name of the thing, separated by dashes (-)', - example: 'this-is-an-example', - minLength: 1, - pattern: '\\\\S', - nullable: true, +export const getModel = (globalOptions: GlobalOptions): string => { + return toYaml( + { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + description: 'Name of the thing, separated by dashes (-)', + example: 'this-is-an-example', + minLength: 1, + pattern: '\\\\S', + nullable: true, + }, }, }, - }); + true, + !globalOptions['no-quote'], + ); }; -export const getModels = (paginationRef: string = '../pagination/model.yml'): string => { - return toYaml({ - type: 'object', - required: ['meta', 'data'], - properties: { - meta: { $ref: paginationRef }, - data: { - type: 'array', - items: { $ref: './model.yml' }, +export const getModels = (globalOptions: GlobalOptions, paginationRef: string = '../pagination/model.yml'): string => { + return toYaml( + { + type: 'object', + required: ['meta', 'data'], + properties: { + meta: { $ref: paginationRef }, + data: { + type: 'array', + items: { $ref: './model.yml' }, + }, }, }, - }); + true, + !globalOptions['no-quote'], + ); }; -export const getParam = (name: string, paramIn: 'header' | 'path' | 'query', type: string = 'string'): string => { - return toYaml({ - in: paramIn, - name, - required: paramIn === 'path', - schema: { type }, - description: `${paramIn} param that does some stuff`, - }); +export const getParam = (globalOptions: GlobalOptions, name: string, paramIn: 'header' | 'path' | 'query', type: string = 'string'): string => { + return toYaml( + { + in: paramIn, + name, + required: paramIn === 'path', + schema: { type }, + description: `${paramIn} param that does some stuff`, + }, + true, + !globalOptions['no-quote'], + ); }; -export const getPaginationModel = (): string => { - return toYaml({ - 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' }, +export const getPaginationModel = (globalOptions: GlobalOptions): string => { + return toYaml( + { + 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' }, + }, }, - }); + true, + !globalOptions['no-quote'], + ); }; diff --git a/src/templates/path.ts b/src/templates/path.ts index 7854ab6..d45335a 100644 --- a/src/templates/path.ts +++ b/src/templates/path.ts @@ -1,163 +1,206 @@ +import { GlobalOptions } from '../cli'; import { capitalize, dashCase, toYaml } from '../lib'; export const getPathIndex = (): string => '{{ autoPathIndexer() }}\n'; -export const getList = (pluralName: string, schemaRef: string, parameters?: { $ref: string }[]): string => { +export const getList = (globalOptions: GlobalOptions, pluralName: string, schemaRef: string, parameters?: { $ref: string }[]): string => { const spaceName = dashCase(pluralName).replace(/-/g, ' '); - return toYaml({ - summary: `List ${spaceName}`, - description: `List ${spaceName}`, - ...(parameters?.length ? { parameters } : {}), - responses: { - '"200"': { - description: 'Success', - content: { - 'application/json': { - schema: { $ref: schemaRef }, + return toYaml( + { + summary: `List ${spaceName}`, + description: `List ${spaceName}`, + ...(parameters?.length ? { parameters } : {}), + responses: { + '"200"': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: schemaRef }, + }, }, }, }, }, - }); + true, + !globalOptions['no-quote'], + ); }; -export const getCreate = (singularName: string, requestSchemaRef: string, responseSchemaRef: string, parameters?: { $ref: string }[]): string => { +export const getCreate = ( + globalOptions: GlobalOptions, + singularName: string, + requestSchemaRef: string, + responseSchemaRef: string, + parameters?: { $ref: string }[], +): string => { const spaceName = dashCase(singularName).replace(/-/g, ' '); - return toYaml({ - summary: `Create a ${spaceName}`, - ...(parameters?.length ? { parameters } : {}), - requestBody: { - required: true, - content: { - 'application/json': { - schema: { $ref: requestSchemaRef }, - }, - }, - }, - responses: { - '"422"': { - description: `Invalid ${spaceName} supplied`, - }, - '"201"': { - description: 'Created', + return toYaml( + { + summary: `Create a ${spaceName}`, + ...(parameters?.length ? { parameters } : {}), + requestBody: { + required: true, content: { 'application/json': { - schema: { $ref: responseSchemaRef }, + schema: { $ref: requestSchemaRef }, + }, + }, + }, + responses: { + '"422"': { + description: `Invalid ${spaceName} supplied`, + }, + '"201"': { + description: 'Created', + content: { + 'application/json': { + schema: { $ref: responseSchemaRef }, + }, }, }, }, }, - }); + true, + !globalOptions['no-quote'], + ); }; -export const getShow = (singularName: string, responseSchemaRef: string, parameters?: { $ref: string }[]): string => { +export const getShow = (globalOptions: GlobalOptions, singularName: string, responseSchemaRef: string, parameters?: { $ref: string }[]): string => { const spaceName = dashCase(singularName).replace(/-/g, ' '); - return toYaml({ - summary: `Show ${spaceName}`, - ...(parameters?.length ? { parameters } : {}), - responses: { - '"404"': { - description: `${capitalize(spaceName)} not found`, - }, - '"200"': { - description: 'Success', - content: { - 'application/json': { - schema: { $ref: responseSchemaRef }, + return toYaml( + { + summary: `Show ${spaceName}`, + ...(parameters?.length ? { parameters } : {}), + responses: { + '"404"': { + description: `${capitalize(spaceName)} not found`, + }, + '"200"': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: responseSchemaRef }, + }, }, }, }, }, - }); + true, + !globalOptions['no-quote'], + ); }; -export const getDelete = (singularName: string, parameters?: { $ref: string }[]): string => { +export const getDelete = (globalOptions: GlobalOptions, singularName: string, parameters?: { $ref: string }[]): string => { const spaceName = dashCase(singularName).replace(/-/g, ' '); - return toYaml({ - summary: `Delete ${spaceName}`, - ...(parameters?.length ? { parameters } : {}), - responses: { - '"404"': { - description: `${capitalize(spaceName)} not found`, - }, - '"204"': { - description: 'Deleted', + return toYaml( + { + summary: `Delete ${spaceName}`, + ...(parameters?.length ? { parameters } : {}), + responses: { + '"404"': { + description: `${capitalize(spaceName)} not found`, + }, + '"204"': { + description: 'Deleted', + }, }, }, - }); + true, + !globalOptions['no-quote'], + ); }; -export const getUpdate = (singularName: string, requestSchemaRef: string, responseSchemaRef: string, parameters?: { $ref: string }[]): string => { +export const getUpdate = ( + globalOptions: GlobalOptions, + singularName: string, + requestSchemaRef: string, + responseSchemaRef: string, + parameters?: { $ref: string }[], +): string => { const spaceName = dashCase(singularName).replace(/-/g, ' '); - return toYaml({ - summary: `Update ${spaceName}`, - ...(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', + return toYaml( + { + summary: `Update ${spaceName}`, + ...(parameters?.length ? { parameters } : {}), + requestBody: { + required: true, content: { 'application/json': { - schema: { $ref: responseSchemaRef }, + schema: { $ref: requestSchemaRef }, + }, + }, + }, + responses: { + '"404"': { + description: `${capitalize(spaceName)} not found`, + }, + '"422"': { + description: `Invalid ${spaceName} supplied`, + }, + '"200"': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: responseSchemaRef }, + }, }, }, }, }, - }); + true, + !globalOptions['no-quote'], + ); }; -export const getReplace = (singularName: string, requestSchemaRef: string, responseSchemaRef: string, parameters?: { $ref: string }[]): string => { +export const getReplace = ( + globalOptions: GlobalOptions, + singularName: string, + requestSchemaRef: string, + responseSchemaRef: string, + parameters?: { $ref: string }[], +): string => { const spaceName = dashCase(singularName).replace(/-/g, ' '); - return toYaml({ - summary: `Create or replace ${spaceName}`, - ...(parameters?.length ? { parameters } : {}), - requestBody: { - required: true, - content: { - 'application/json': { - schema: { $ref: requestSchemaRef }, - }, - }, - }, - responses: { - '"422"': { - description: `Invalid ${spaceName} supplied`, - }, - '"201"': { - description: 'Created', + return toYaml( + { + summary: `Create or replace ${spaceName}`, + ...(parameters?.length ? { parameters } : {}), + requestBody: { + required: true, content: { 'application/json': { - schema: { $ref: responseSchemaRef }, + schema: { $ref: requestSchemaRef }, }, }, }, - '"200"': { - description: 'Replaced', - content: { - 'application/json': { - schema: { $ref: responseSchemaRef }, + responses: { + '"422"': { + description: `Invalid ${spaceName} supplied`, + }, + '"201"': { + description: 'Created', + content: { + 'application/json': { + schema: { $ref: responseSchemaRef }, + }, + }, + }, + '"200"': { + description: 'Replaced', + content: { + 'application/json': { + schema: { $ref: responseSchemaRef }, + }, }, }, }, }, - }); + true, + !globalOptions['no-quote'], + ); }; diff --git a/test/e2e.spec.ts b/test/e2e.spec.ts index d18e5fc..1df6c47 100644 --- a/test/e2e.spec.ts +++ b/test/e2e.spec.ts @@ -811,4 +811,487 @@ describe('e2e.spec.ts', async () => { assert.strictEqual(await getFile(file.replace('/root-ref-off/', '/root-ref-base/')), await getFile(file), 'content mismatch'); } }); + + await it('can generate an api without quoted yaml values', async () => { + const files = await cli( + toArgv(` + path users/:userId -crudl + path users/:userId/photos/:photoId -crudl + --quiet + --no-quote + --output test/output/e2e/quotes + `), + ); + + assert.deepStrictEqual( + Object.keys(files).sort(), + [ + 'test/output/e2e/quotes/.boatsrc', + 'test/output/e2e/quotes/src/components/parameters/index.yml', + 'test/output/e2e/quotes/src/components/parameters/pathPhotoId.yml', + 'test/output/e2e/quotes/src/components/parameters/pathUserId.yml', + 'test/output/e2e/quotes/src/components/parameters/queryLimit.yml', + 'test/output/e2e/quotes/src/components/parameters/queryOffset.yml', + 'test/output/e2e/quotes/src/components/schemas/index.yml', + 'test/output/e2e/quotes/src/components/schemas/pagination/model.yml', + 'test/output/e2e/quotes/src/components/schemas/photo/model.yml', + 'test/output/e2e/quotes/src/components/schemas/photo/models.yml', + 'test/output/e2e/quotes/src/components/schemas/photo/patch.yml', + 'test/output/e2e/quotes/src/components/schemas/photo/post.yml', + 'test/output/e2e/quotes/src/components/schemas/user/model.yml', + 'test/output/e2e/quotes/src/components/schemas/user/models.yml', + 'test/output/e2e/quotes/src/components/schemas/user/patch.yml', + 'test/output/e2e/quotes/src/components/schemas/user/post.yml', + 'test/output/e2e/quotes/src/index.yml', + 'test/output/e2e/quotes/src/paths/index.yml', + 'test/output/e2e/quotes/src/paths/users/get.yml', + 'test/output/e2e/quotes/src/paths/users/post.yml', + 'test/output/e2e/quotes/src/paths/users/{userId}/delete.yml', + 'test/output/e2e/quotes/src/paths/users/{userId}/get.yml', + 'test/output/e2e/quotes/src/paths/users/{userId}/patch.yml', + 'test/output/e2e/quotes/src/paths/users/{userId}/photos/get.yml', + 'test/output/e2e/quotes/src/paths/users/{userId}/photos/post.yml', + 'test/output/e2e/quotes/src/paths/users/{userId}/photos/{photoId}/delete.yml', + 'test/output/e2e/quotes/src/paths/users/{userId}/photos/{photoId}/get.yml', + 'test/output/e2e/quotes/src/paths/users/{userId}/photos/{photoId}/patch.yml', + ], + 'file mismatch', + ); + + assert.strictEqual( + await getFile('test/output/e2e/quotes/.boatsrc'), + trimIndent`\ + { + "picomatchOptions": { + "bash": true + }, + "fancyPluralization": true, + "paths": {} + } + `, + ); + assert.strictEqual(await getFile('test/output/e2e/quotes/src/components/parameters/index.yml'), '{{ autoComponentIndexer() }}\n'); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/parameters/pathPhotoId.yml'), + trimIndent`\ + in: path + name: photoId + required: true + schema: + type: string + description: path param that does some stuff + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/parameters/pathUserId.yml'), + trimIndent`\ + in: path + name: userId + required: true + schema: + type: string + description: path param that does some stuff + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/parameters/queryLimit.yml'), + trimIndent`\ + in: query + name: limit + required: false + schema: + type: integer + description: query param that does some stuff + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/parameters/queryOffset.yml'), + trimIndent`\ + in: query + name: offset + required: false + schema: + type: integer + description: query param that does some stuff + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/schemas/pagination/model.yml'), + trimIndent`\ + 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 + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/schemas/photo/model.yml'), + trimIndent`\ + type: object + required: + - name + properties: + name: + type: string + description: Name of the thing, separated by dashes (-) + example: this-is-an-example + minLength: 1 + pattern: \\\\S + nullable: true + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/schemas/photo/models.yml'), + trimIndent`\ + type: object + required: + - meta + - data + properties: + meta: + $ref: ../pagination/model.yml + data: + type: array + items: + $ref: ./model.yml + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/schemas/photo/patch.yml'), + trimIndent`\ + type: object + required: + - name + properties: + name: + type: string + description: Name of the thing, separated by dashes (-) + example: this-is-an-example + minLength: 1 + pattern: \\\\S + nullable: true + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/schemas/photo/post.yml'), + trimIndent`\ + type: object + required: + - name + properties: + name: + type: string + description: Name of the thing, separated by dashes (-) + example: this-is-an-example + minLength: 1 + pattern: \\\\S + nullable: true + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/schemas/user/model.yml'), + trimIndent`\ + type: object + required: + - name + properties: + name: + type: string + description: Name of the thing, separated by dashes (-) + example: this-is-an-example + minLength: 1 + pattern: \\\\S + nullable: true + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/schemas/user/models.yml'), + trimIndent`\ + type: object + required: + - meta + - data + properties: + meta: + $ref: ../pagination/model.yml + data: + type: array + items: + $ref: ./model.yml + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/schemas/user/patch.yml'), + trimIndent`\ + type: object + required: + - name + properties: + name: + type: string + description: Name of the thing, separated by dashes (-) + example: this-is-an-example + minLength: 1 + pattern: \\\\S + nullable: true + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/components/schemas/user/post.yml'), + trimIndent`\ + type: object + required: + - name + properties: + name: + type: string + description: Name of the thing, separated by dashes (-) + example: this-is-an-example + minLength: 1 + pattern: \\\\S + nullable: true + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/index.yml'), + trimIndent`\ + openapi: 3.1.0 + info: + version: {{ packageJson('version') }} + title: {{ packageJson('name') }} + description: our sweet api + contact: + name: acrontum + email: support@acrontum.de + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + servers: + - url: /v1 + paths: + $ref: paths/index.yml + components: + parameters: + $ref: components/parameters/index.yml + schemas: + $ref: components/schemas/index.yml + + {{ + inject([ + { + toAllOperations: { + content: " + tags: + - {{ autoTag() }} + operationId: {{ uniqueOpId() }} + " + } + } + ]) + }} + `, + ); + assert.strictEqual(await getFile('test/output/e2e/quotes/src/paths/index.yml'), trimIndent`{{ autoPathIndexer() }}\n`); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/paths/users/get.yml'), + trimIndent`\ + summary: List users + description: List users + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: ../../components/schemas/user/models.yml + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/paths/users/post.yml'), + trimIndent`\ + summary: Create a user + requestBody: + required: true + content: + application/json: + schema: + $ref: ../../components/schemas/user/post.yml + responses: + "422": + description: Invalid user supplied + "201": + description: Created + content: + application/json: + schema: + $ref: ../../components/schemas/user/model.yml + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/paths/users/{userId}/delete.yml'), + trimIndent`\ + summary: Delete user + parameters: + - $ref: ../../../components/parameters/pathUserId.yml + responses: + "404": + description: User not found + "204": + description: Deleted + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/paths/users/{userId}/get.yml'), + trimIndent`\ + summary: Show user + parameters: + - $ref: ../../../components/parameters/pathUserId.yml + responses: + "404": + description: User not found + "200": + description: Success + content: + application/json: + schema: + $ref: ../../../components/schemas/user/model.yml + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/paths/users/{userId}/patch.yml'), + trimIndent`\ + summary: Update user + parameters: + - $ref: ../../../components/parameters/pathUserId.yml + requestBody: + required: true + content: + application/json: + schema: + $ref: ../../../components/schemas/user/patch.yml + responses: + "404": + description: User not found + "422": + description: Invalid user supplied + "200": + description: Success + content: + application/json: + schema: + $ref: ../../../components/schemas/user/model.yml + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/paths/users/{userId}/photos/get.yml'), + trimIndent`\ + summary: List photos + description: List photos + parameters: + - $ref: ../../../../components/parameters/pathUserId.yml + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: ../../../../components/schemas/photo/models.yml + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/paths/users/{userId}/photos/post.yml'), + trimIndent`\ + summary: Create a photo + parameters: + - $ref: ../../../../components/parameters/pathUserId.yml + requestBody: + required: true + content: + application/json: + schema: + $ref: ../../../../components/schemas/photo/post.yml + responses: + "422": + description: Invalid photo supplied + "201": + description: Created + content: + application/json: + schema: + $ref: ../../../../components/schemas/photo/model.yml + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/paths/users/{userId}/photos/{photoId}/delete.yml'), + trimIndent`\ + summary: Delete photo + parameters: + - $ref: ../../../../../components/parameters/pathUserId.yml + - $ref: ../../../../../components/parameters/pathPhotoId.yml + responses: + "404": + description: Photo not found + "204": + description: Deleted + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/paths/users/{userId}/photos/{photoId}/get.yml'), + trimIndent`\ + summary: Show photo + parameters: + - $ref: ../../../../../components/parameters/pathUserId.yml + - $ref: ../../../../../components/parameters/pathPhotoId.yml + responses: + "404": + description: Photo not found + "200": + description: Success + content: + application/json: + schema: + $ref: ../../../../../components/schemas/photo/model.yml + `, + ); + assert.strictEqual( + await getFile('test/output/e2e/quotes/src/paths/users/{userId}/photos/{photoId}/patch.yml'), + trimIndent`\ + summary: Update photo + parameters: + - $ref: ../../../../../components/parameters/pathUserId.yml + - $ref: ../../../../../components/parameters/pathPhotoId.yml + requestBody: + required: true + content: + application/json: + schema: + $ref: ../../../../../components/schemas/photo/patch.yml + responses: + "404": + description: Photo not found + "422": + description: Invalid photo supplied + "200": + description: Success + content: + application/json: + schema: + $ref: ../../../../../components/schemas/photo/model.yml + `, + ); + }); }).catch(console.warn); diff --git a/test/path.spec.ts b/test/path.spec.ts index 192b852..45514b6 100644 --- a/test/path.spec.ts +++ b/test/path.spec.ts @@ -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 e358678..ca93171 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) => {