diff --git a/CICD/compareTestResults.js b/CICD/compareTestResults.js index a65ccc1..b055abf 100644 --- a/CICD/compareTestResults.js +++ b/CICD/compareTestResults.js @@ -1,6 +1,6 @@ const fs = require("fs"); -const log = require("../src/log.js"); +const log = require("../src/common/log"); log.setLogLevel(log.logLevels.verbose); diff --git a/CICD/updateWebpage.js b/CICD/updateWebpage.js index b53eb89..c7cb6e8 100644 --- a/CICD/updateWebpage.js +++ b/CICD/updateWebpage.js @@ -1,6 +1,6 @@ const fs = require("fs"); -const log = require("../src/log.js"); +const log = require("../src/common/log"); // This regex extracts the generator table from README.md. const tableRegex = diff --git a/README.md b/README.md index c979e6a..b9de223 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,26 @@ Options: -h, --help Display help for command ``` +Individual generators may have their own options. Try it out: + +``` +% openapi-forge generator-options https://github.com/ScottLogic/openapi-forge-javascript.git +Usage: openapi-forge generator-options + +This generator has a number of additional options which can be supplied when executing the 'forge' command. + +Options: + --generator.moduleFormat The module format to use for the generated + code. (choices: "commonjs", "esmodule", + default: "commonjs") +``` + +and then + +``` +% openapi-forge forge https://petstore3.swagger.io/api/v3/openapi.json https://github.com/ScottLogic/openapi-forge-javascript.git --generator.moduleFormat "esmodule" +``` + ## Client generation In order to generate a client you need a suitable API specification, this can be supplied as a URL or a local file and can be in JSON or YML format. For this tutorial, we’ll use the Swagger Petstore API: diff --git a/src/generatorResolver.js b/src/common/generatorResolver.js similarity index 100% rename from src/generatorResolver.js rename to src/common/generatorResolver.js diff --git a/src/log.js b/src/common/log.js similarity index 100% rename from src/log.js rename to src/common/log.js diff --git a/src/forge/index.js b/src/forge/index.js new file mode 100644 index 0000000..414b58e --- /dev/null +++ b/src/forge/index.js @@ -0,0 +1,71 @@ +const path = require("path"); +const fs = require("fs"); + +const generate = require("../generate"); +const generatorResolver = require("../common/generatorResolver"); +const { + configToCommanderOptions, + generatorOptionsPrefix, +} = require("../generatorOptions/generatorOptions"); + +const forgeCommand = function (program) { + const command = program + .command("forge") + .description( + "Forge the API client from an OpenAPI specification. This command takes an OpenAPI schema, and uses the given generator to create a client library." + ) + .argument("", "An OpenAPI schema, either a URL or a file path") + .argument( + "", + "Git URL, file path or npm package of a language-specific generator" + ) + .option( + "-e, --exclude ", + "A glob pattern that excludes files from the generator in the output", + "" + ) + .option( + "-o, --output ", + "The path where the generated client API will be written", + "." + ) + .option("-s, --skipValidation", "Skip schema validation") + .option( + "-l, --logLevel ", + "Sets the logging level, options are: quiet ('quiet', 'q' or '0'), standard (default) ('standard', 's' or '1'), verbose ('verbose', 'v' or '2')", + "1" + ) + .allowUnknownOption() + .action(async (schema, generatorPathOrUrl) => { + const generatorPath = generatorResolver.getGenerator(generatorPathOrUrl); + const configFile = path.join(generatorPath, "config.json"); + + // re-configure the command to generate the API + command.allowUnknownOption(false).action(async (_, __, options) => { + // set the additional options as environment variables + const generatorOptions = Object.keys(options).filter((key) => + key.startsWith(generatorOptionsPrefix) + ); + generatorOptions.forEach((option) => { + const optionName = option.substring(generatorOptionsPrefix.length); + process.env[optionName] = options[option]; + }); + + await generate(schema, generatorPath, options); + generatorResolver.cleanup(); + }); + + // add the additional options from the generator's config.json file + if (fs.existsSync(configFile)) { + const config = JSON.parse(fs.readFileSync(configFile, "utf8")); + configToCommanderOptions(config).forEach((option) => { + command.addOption(option); + }); + } + + // parse the command line arguments, and perform generation (on success) + command.parse(process.argv); + }); +}; + +module.exports = forgeCommand; diff --git a/src/generate.js b/src/generate.js index 96f630b..3ed2ff5 100644 --- a/src/generate.js +++ b/src/generate.js @@ -1,3 +1,6 @@ +// IMPORTANT: This file is used in the generators, so you will need to change them if this file is moved. +// See issue https://github.com/ScottLogic/openapi-forge/issues/158 + const fs = require("fs"); const path = require("path"); @@ -7,9 +10,9 @@ const minimatch = require("minimatch"); const fetch = require("node-fetch"); const { parse } = require("yaml"); -const generatorResolver = require("./generatorResolver"); +const generatorResolver = require("./common/generatorResolver"); const helpers = require("./helpers"); -const log = require("./log"); +const log = require("./common/log"); const transformers = require("./transformers"); const SwaggerParser = require("@apidevtools/swagger-parser"); const converter = require("swagger2openapi"); @@ -153,17 +156,15 @@ function getFilesInFolders(basePath, partialPath = "") { }); } -async function generate(schemaPathOrUrl, generatorPathOrUrl, options) { +// IMPORTANT: This function is used in the generators, so be careful when modifying! +// See issue https://github.com/ScottLogic/openapi-forge/issues/158 +async function generate(schemaPathOrUrl, generatorPath, options) { log.setLogLevel(options.logLevel); log.logTitle(); let exception = null; let numberOfDiscoveredModels = 0; let numberOfDiscoveredEndpoints = 0; try { - log.standard(`Loading generator from '${generatorPathOrUrl}'`); - - let generatorPath = generatorResolver.getGenerator(generatorPathOrUrl); - log.standard("Validating generator"); validateGenerator(generatorPath); @@ -265,8 +266,6 @@ async function generate(schemaPathOrUrl, generatorPathOrUrl, options) { } } catch (e) { exception = e; - } finally { - generatorResolver.cleanup(); } if (exception === null) { diff --git a/src/generatorOptions/generatorOptions.js b/src/generatorOptions/generatorOptions.js new file mode 100644 index 0000000..ed0a173 --- /dev/null +++ b/src/generatorOptions/generatorOptions.js @@ -0,0 +1,64 @@ +const fs = require("fs"); +const path = require("path"); + +const { Option, Command } = require("commander"); + +const generatorResolver = require("../common/generatorResolver"); + +const generatorOptionsPrefix = "generator."; + +async function generatorOptions(generatorPathOrUrl) { + let optionsHelp = ""; + const generatorPath = generatorResolver.getGenerator(generatorPathOrUrl); + const configFile = path.join(generatorPath, "config.json"); + + if (!fs.existsSync(configFile)) { + optionsHelp += "The generator has no additional options"; + } else { + const config = JSON.parse(fs.readFileSync(configFile, "utf8")); + const options = configToCommanderOptions(config); + + // we use commander to create the formatted help for these options + const command = new Command(); + options.forEach((option) => command.addOption(option)); + const commanderHelp = command.helpInformation(); + + // extract the parts we are interested in + const lines = commanderHelp.split("\n"); + lines.splice(0, 2); + lines.splice(lines.length - 2, 2); + + optionsHelp += + "This generator has a number of additional options which can be supplied when executing the 'forge' command.\n\n"; + optionsHelp += lines.join("\n"); + } + + generatorResolver.cleanup(); + + return optionsHelp; +} + +// we use the commander library to parse the command line arguments and provide +// help text. This function converts the config.json file into a set of options +function configToCommanderOptions(config) { + return Object.keys(config).map((optionName) => { + const option = config[optionName]; + const commanderOption = new Option( + `--${generatorOptionsPrefix}${optionName} ` + ); + if (option.description) { + commanderOption.description = option.description; + } + if (option.choices) { + commanderOption.choices(option.choices); + commanderOption.default(option.choices[0]); + } + return commanderOption; + }); +} + +module.exports = { + generatorOptions, + configToCommanderOptions, + generatorOptionsPrefix, +}; diff --git a/src/generatorOptions/index.js b/src/generatorOptions/index.js new file mode 100644 index 0000000..52ff6c8 --- /dev/null +++ b/src/generatorOptions/index.js @@ -0,0 +1,18 @@ +const { generatorOptions } = require("./generatorOptions"); + +const generatorOptionsCommand = function (program) { + program + .command("generator-options") + .description( + "List the options available for a generator. Some generators take additional options that configure their output, this command lists and describes the options available." + ) + .argument( + "", + "Git URL, file path or npm package of a language-specific generator" + ) + .action(async (generator) => { + console.log(await generatorOptions(generator)); + }); +}; + +module.exports = generatorOptionsCommand; diff --git a/src/index.js b/src/index.js index da90e89..141338e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,64 +1,19 @@ #! /usr/bin/env node -const { Command, Option } = require("commander"); -const generate = require("./generate"); +const { Command } = require("commander"); + const packageJson = require("../package.json"); -const testGenerators = require("./testGenerators"); +const forgeCommand = require("./forge"); +const generatorOptionsCommand = require("./generatorOptions"); +const testGeneratorsCommand = require("./testGenerators"); const program = new Command(); program.name("openapi-forge"); program.version(packageJson.version); -program - .command("forge") - .description( - "Forge the API client from an OpenAPI specification. This command takes an OpenAPI schema, and uses the given generator to create a client library." - ) - .argument("", "An OpenAPI schema, either a URL or a file path") - .argument( - "", - "Git URL, file path or npm package of a language-specific generator" - ) - .option( - "-e, --exclude ", - "A glob pattern that excludes files from the generator in the output", - "" - ) - .option( - "-o, --output ", - "The path where the generated client API will be written", - "." - ) - .option("-s, --skipValidation", "Skip schema validation") - .option( - "-l, --logLevel ", - "Sets the logging level, options are: quiet ('quiet', 'q' or '0'), standard (default) ('standard', 's' or '1'), verbose ('verbose', 'v' or '2')", - "1" - ) - .action(async (schema, template, options) => { - generate(schema, template, options); - }); - -program - .command("test-generators") - .description("Test language specific generators.") - .option( - "-g, --generators ", - "Generators to test, e.g. openapi-forge-typescript" - ) - .option( - "-l, --logLevel ", - "Sets the logging level, options are: quiet ('quiet', 'q' or '0'), standard (default) ('standard', 's' or '1'), verbose ('verbose', 'v' or '2')", - "1" - ) - .addOption( - new Option("-f, --format ", "Output format") - .choices(["table", "json"]) - .default("table") - ) - .action(async (options) => { - testGenerators.testGenerators(options); - }); +forgeCommand(program); +generatorOptionsCommand(program); +testGeneratorsCommand(program); program.parse(); diff --git a/src/testGenerators/index.js b/src/testGenerators/index.js new file mode 100644 index 0000000..17b5349 --- /dev/null +++ b/src/testGenerators/index.js @@ -0,0 +1,27 @@ +const { Option } = require("commander"); +const testGenerators = require("./testGenerators"); + +const testGeneratorsCommand = function (program) { + program + .command("test-generators") + .description("Test language specific generators.") + .option( + "-g, --generators ", + "Generators to test, e.g. openapi-forge-typescript" + ) + .option( + "-l, --logLevel ", + "Sets the logging level, options are: quiet ('quiet', 'q' or '0'), standard (default) ('standard', 's' or '1'), verbose ('verbose', 'v' or '2')", + "1" + ) + .addOption( + new Option("-f, --format ", "Output format") + .choices(["table", "json"]) + .default("table") + ) + .action(async (options) => { + testGenerators.testGenerators(options); + }); +}; + +module.exports = testGeneratorsCommand; diff --git a/src/testGenerators.js b/src/testGenerators/testGenerators.js similarity index 76% rename from src/testGenerators.js rename to src/testGenerators/testGenerators.js index 1d4d418..dfe8cb6 100644 --- a/src/testGenerators.js +++ b/src/testGenerators/testGenerators.js @@ -1,7 +1,7 @@ const path = require("path"); const shell = require("shelljs"); -const log = require("./log"); +const log = require("../common/log"); function isJson(str) { try { @@ -52,7 +52,18 @@ function testGenerators(options) { options.generators.forEach((generator) => { try { const generatorPath = path.resolve( - path.join(__dirname, "..", "..", generator) + // Assuming the file structure: + // - openapi-forge + // |- src + // |- testGenerators + // |- testGenerators.js + // - openapi-forge-javascript + // - openapi-forge-... + // + // The generators must be in the above location, otherwise this test-generators command will not work. + // If you move this file, the test-generators command will not work unless you fix the line below. + // We need to go up three directories to get to the root of openapi-forge: + path.join(__dirname, "..", "..", "..", generator) ); log.standard(`Starting tests for generator ${generator}`); diff --git a/test/generate.test.js b/test/generate.test.js index 73ecdb7..2070def 100644 --- a/test/generate.test.js +++ b/test/generate.test.js @@ -3,12 +3,12 @@ const path = require("path"); const Handlebars = require("handlebars"); const generate = require("../src/generate"); -const generatorResolver = require("../src/generatorResolver"); +const generatorResolver = require("../src/common/generatorResolver"); jest.mock("fs"); jest.mock("path"); jest.mock("handlebars"); -jest.mock("../src/generatorResolver"); +jest.mock("../src/common/generatorResolver"); describe("generate", () => { const generatorPath = "generatorPath"; @@ -21,7 +21,6 @@ describe("generate", () => { beforeAll(() => { // For these tests, we don't really care about the responses for these: path.resolve.mockImplementation((path) => path); - generatorResolver.getGenerator.mockImplementation((path) => path); fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(fakeSchema); generatorResolver.isUrl.mockReturnValue(false); diff --git a/test/generatorOptions.test.js b/test/generatorOptions.test.js new file mode 100644 index 0000000..61d1735 --- /dev/null +++ b/test/generatorOptions.test.js @@ -0,0 +1,87 @@ +const fs = require("fs"); + +const { + generatorOptions, +} = require("../src/generatorOptions/generatorOptions"); +const generatorResolver = require("../src/common/generatorResolver"); + +jest.mock("fs"); +jest.mock("../src/common/generatorResolver"); + +describe("generate", () => { + beforeAll(() => { + generatorResolver.getGenerator.mockImplementation((path) => path); + }); + + beforeEach(() => { + jest.clearAllMocks(); + fs.existsSync.mockReturnValue(true); + }); + + it("should indicate if the generator doesn't have any additional options", async () => { + fs.existsSync.mockReturnValue(false); + const optionsHelp = await generatorOptions("some/path"); + expect(optionsHelp).toEqual("The generator has no additional options"); + }); + + it("should include the description in the help output", async () => { + const config = { + moduleFormat: { + description: "The module format to use for the generated code.", + }, + }; + fs.readFileSync.mockReturnValue(JSON.stringify(config)); + const optionsHelp = await generatorOptions("some/path"); + expect(optionsHelp).toEqual( + // Not checking the full string because extra whitespace is created: + expect.stringMatching( + /moduleFormat.*The module format to use for the generated/ + ) + ); + }); + + it("should treat the description as optional", async () => { + const config = { + moduleFormat: {}, + }; + fs.readFileSync.mockReturnValue(JSON.stringify(config)); + const optionsHelp = await generatorOptions("some/path"); + expect(optionsHelp).toEqual(expect.stringMatching(/moduleFormat/)); + }); + + it("should handle multiple options", async () => { + const config = { + optionOne: { + description: "description One.", + }, + optionTwo: { + description: "description Two.", + }, + }; + + fs.readFileSync.mockReturnValue(JSON.stringify(config)); + const optionsHelp = await generatorOptions("some/path"); + + expect(optionsHelp).toEqual( + expect.stringMatching(/optionOne.*description One./) + ); + expect(optionsHelp).toEqual( + expect.stringMatching(/optionTwo.*description Two./) + ); + }); + + it("should output choices", async () => { + const config = { + moduleFormat: { + choices: ["cjs", "esm"], + }, + }; + fs.readFileSync.mockReturnValue(JSON.stringify(config)); + const optionsHelp = await generatorOptions("some/path"); + expect(optionsHelp).toEqual( + expect.stringMatching( + /moduleFormat.*(choices: "cjs", "esm", default: "cjs")/ + ) + ); + }); +});