From f230e95e6b8d03401cfc83c135ffc19a69cb122a Mon Sep 17 00:00:00 2001 From: Keshav Malik Date: Tue, 23 Jun 2026 21:42:44 +0530 Subject: [PATCH] feat: add OpenAPI spec generation --- CHANGELOG.md | 4 + README.md | 3 + bin/servergen.js | 3 + docs/examples/express.md | 4 + lib/app_generator.js | 11 ++ lib/file_generator.js | 179 ++++++++++++++++++++++++++ lib/validator.js | 4 + tests/integration/integration.test.js | 37 ++++++ tests/integration/packaged.test.js | 14 +- tests/unit/app_generator.test.js | 55 +++++++- tests/unit/file_generator.test.js | 39 ++++++ tests/unit/validator.test.js | 17 +++ 12 files changed, 365 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3674367..1fa4f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add an Express-only `--openapi` option that generates `docs/openapi.yaml` and documents it in the generated README. + ## 2.2.3 - 2026-06-23 - Improve README trust signals with status badges, a faster quick start, generated-output details, and release integrity notes. diff --git a/README.md b/README.md index 0dcadee..a1e0a61 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ generated app unless `--skip-install` is used. | `--framework node` | Plain Node.js HTTP server with `/`, `/about`, `/contact`, and `/health`, plus MVC folders, Docker files, `.gitignore`, generated `README.md`, and `package.json`. | | `--view ejs`, `pug`, or `hbs` | Adds the selected Express view template and renders it from `/`. | | `--db` | Adds Mongoose, `config/mongoose.js`, and `MONGODB_URI` in `.env.example` for Express apps. | +| `--openapi` | Adds `docs/openapi.yaml`, a static OpenAPI 3.0 spec for the generated Express routes. | Generated apps include `npm start` and `npm run dev`. Express apps also include `npm test`. @@ -64,6 +65,7 @@ servergen [options] [name] -f, --framework framework: express | node (default: "express") -v, --view view engine (express only): ejs | pug | hbs --db add Mongoose and a MongoDB config (express only) + --openapi generate an OpenAPI spec file (express only) -p, --port port for the generated app (1-65535) (default: "3000") --skip-install skip the npm install step --debug enable debug logging @@ -77,6 +79,7 @@ npx servergen@latest my-api npx servergen@latest my-api --framework node npx servergen@latest my-api --view ejs npx servergen@latest my-api --db +npx servergen@latest my-api --openapi npx servergen@latest my-api --port 8080 npx servergen@latest my-api --skip-install npx servergen@latest --name my-api diff --git a/bin/servergen.js b/bin/servergen.js index 9247d2d..6dbfd99 100644 --- a/bin/servergen.js +++ b/bin/servergen.js @@ -31,6 +31,7 @@ program .option('-f, --framework ', 'framework: express | node', 'express') .option('-v, --view ', 'view engine (express only): ejs | pug | hbs') .option('--db', 'add Mongoose and a MongoDB config (express only)') + .option('--openapi', 'generate an OpenAPI spec file (express only)') .option('-p, --port ', 'port for the generated app (1-65535)', '3000') .option('--skip-install', 'skip the npm install step') .option('--debug', 'enable debug logging') @@ -42,6 +43,7 @@ Examples: $ servergen my-api -f node create a Node app $ servergen my-api -v ejs Express app with the EJS view engine $ servergen my-api --db Express app with Mongoose/MongoDB + $ servergen my-api --openapi Express app with docs/openapi.yaml $ servergen my-api -p 8080 use a custom port $ servergen my-api --skip-install scaffold without running npm install $ servergen --name my-api name via flag (equivalent to positional)` @@ -103,6 +105,7 @@ const main = async () => { framework: options.framework, view: options.view, db: options.db, + openapi: options.openapi, port, skipInstall, config, diff --git a/docs/examples/express.md b/docs/examples/express.md index a8a7fdf..9d50c58 100644 --- a/docs/examples/express.md +++ b/docs/examples/express.md @@ -21,6 +21,10 @@ Generated files and directories include: - `package.json` with `start`, `dev`, and `test` scripts - `.env.example`, `.gitignore`, `.dockerignore`, `Dockerfile`, and `README.md` +Pass `--openapi` to also generate `docs/openapi.yaml`, a static OpenAPI 3.0 +spec for `/`, `POST /`, and `/health`. The spec uses the selected app name and +custom `--port` value in its metadata and local server URL. + Because the command runs installation by default, npm also creates `node_modules/` and `package-lock.json` inside `hello-express/`. The generated Express app depends on `express`, `cors`, and `dotenv`. It does not generate auth, ORM models, or production deployment configuration. diff --git a/lib/app_generator.js b/lib/app_generator.js index 4b2bbca..4f4e459 100644 --- a/lib/app_generator.js +++ b/lib/app_generator.js @@ -18,6 +18,7 @@ class AppGenerator { * @param {string} options.framework - The framework type ('node' or 'express'). * @param {string|null} options.view - The view engine name. * @param {boolean} options.db - Whether to include database configuration. + * @param {boolean} options.openapi - Whether to generate an OpenAPI spec. * @param {number} options.port - The port number for the app. * @param {boolean} options.skipInstall - Whether to skip npm install. * @param {Object} options.config - Configuration object from lib/config. @@ -32,6 +33,7 @@ class AppGenerator { this.framework = options.framework || 'express'; this.view = options.view; this.db = options.db; + this.openapi = options.openapi || false; this.port = options.port || 3000; this.skipInstall = options.skipInstall || false; this.config = options.config; @@ -55,6 +57,7 @@ class AppGenerator { this.logger?.debug('Starting app generation', { appName: this.appName, framework: this.framework, + openapi: this.openapi, port: this.port, skipInstall: this.skipInstall, }); @@ -159,6 +162,7 @@ class AppGenerator { addSupportFiles() { const supportOptions = { db: Boolean(this.db), + openapi: Boolean(this.openapi), port: this.port, }; @@ -169,6 +173,13 @@ class AppGenerator { appName: this.appName, }); this.fileCreator.addEnvExample(this.folderDir, this.templatesDir, supportOptions); + if (this.framework === 'express' && this.openapi) { + this.fileCreator.addOpenApiSpec(this.folderDir, { + appName: this.appName, + port: this.port, + view: this.view, + }); + } } /** diff --git a/lib/file_generator.js b/lib/file_generator.js index 73b5a0f..5a89df8 100644 --- a/lib/file_generator.js +++ b/lib/file_generator.js @@ -291,10 +291,188 @@ const addReadme = (folderDir, templatesDir, options = {}) => { .replace(/http:\/\/localhost:3000/g, `http://localhost:${options.port}`) .replace(/http:\/\/127\.0\.0\.1:3000/g, `http://127.0.0.1:${options.port}`); } + if (options.openapi) { + readme += ` +## OpenAPI Specification + +This app includes a static OpenAPI 3.0 spec at \`docs/openapi.yaml\`. + +Inspect it with an OpenAPI viewer, import it into API tooling, or serve it with +your preferred static file middleware if you want browser-accessible docs. +`; + } fs.writeFileSync(generatedReadmePath, readme, 'utf-8'); } }; +const yamlSingleQuoted = (value) => `'${String(value).replace(/'/g, "''")}'`; + +/** + * Adds an OpenAPI specification for generated Express apps. + * @param {string} folderDir - The application directory path. + * @param {Object} options - OpenAPI generation options. + * @param {string} options.appName - Name of the generated app. + * @param {number} options.port - Port configured for the generated app. + * @param {string|null} options.view - The selected Express view engine. + */ +const addOpenApiSpec = (folderDir, options = {}) => { + const appName = options.appName || 'servergen-app'; + const port = options.port || 3000; + const hasView = Boolean(options.view); + const rootGetResponse = hasView + ? ` description: Rendered welcome page + content: + text/html: + schema: + type: string + example: 'Welcome to ServerGen!'` + : ` description: Welcome response + content: + application/json: + schema: + $ref: '#/components/schemas/WelcomeResponse' + examples: + welcome: + value: + message: Welcome to ServerGen!`; + + const spec = `openapi: 3.0.3 +info: + title: ${yamlSingleQuoted(`${appName} API`)} + version: 1.0.0 + description: ${yamlSingleQuoted(`API specification for the ${appName} Express app generated by ServerGen.`)} +servers: + - url: http://localhost:${port} + description: Local development server +paths: + /: + get: + summary: Root endpoint + operationId: getRoot + responses: + '200': +${rootGetResponse} + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Echo a JSON payload with the welcome response + operationId: postRoot + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: true + example: + name: ServerGen + responses: + '200': + description: Welcome response with the submitted request body + content: + application/json: + schema: + $ref: '#/components/schemas/PostRootResponse' + examples: + echoedPayload: + value: + message: Welcome to ServerGen! + data: + name: ServerGen + '400': + description: Invalid JSON request body + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /health: + get: + summary: Health check + operationId: getHealth + responses: + '200': + description: Service health status + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + examples: + ok: + value: + status: ok + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' +components: + responses: + NotFound: + description: Route not found + content: + text/html: + schema: + type: string + examples: + notFound: + value: Cannot GET /missing + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + internalServerError: + value: + error: Internal Server Error + schemas: + WelcomeResponse: + type: object + required: + - message + properties: + message: + type: string + example: Welcome to ServerGen! + PostRootResponse: + type: object + required: + - message + properties: + message: + type: string + example: Welcome to ServerGen! + data: + type: object + additionalProperties: true + HealthResponse: + type: object + required: + - status + properties: + status: + type: string + example: ok + ErrorResponse: + type: object + required: + - error + properties: + error: + type: string +`; + + const docsDir = path.join(folderDir, 'docs'); + fs.ensureDirSync(docsDir); + fs.writeFileSync(path.join(docsDir, 'openapi.yaml'), spec, 'utf-8'); +}; + /** * Adds .env.example file to the application. * @param {string} folderDir - The application directory path. @@ -328,5 +506,6 @@ export { addGitIgnore, addDockerSupport, addReadme, + addOpenApiSpec, addEnvExample, }; diff --git a/lib/validator.js b/lib/validator.js index 8ad841d..ceb3d41 100644 --- a/lib/validator.js +++ b/lib/validator.js @@ -30,6 +30,10 @@ export const validateOptions = (options, validationRules) => { errors.push('The --db option (Mongoose) is only supported with the express framework. Use --framework express or remove --db.'); } + if (options.framework === 'node' && options.openapi) { + errors.push('The --openapi option is only supported with the express framework. Use --framework express or remove --openapi.'); + } + if (options.port !== undefined && options.port !== null) { const portNum = Number(options.port); if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) { diff --git a/tests/integration/integration.test.js b/tests/integration/integration.test.js index 8011a7e..5533cf9 100644 --- a/tests/integration/integration.test.js +++ b/tests/integration/integration.test.js @@ -48,6 +48,7 @@ describe('CLI Integration', () => { expect(output).toContain('Usage:'); expect(output).toContain('-n, --name'); expect(output).toContain('-f, --framework'); + expect(output).toContain('--openapi'); }); it('shows usage examples', () => { @@ -136,6 +137,34 @@ describe('CLI Integration', () => { expect(pkg.dependencies.mongoose).toBeDefined(); expect(fs.existsSync(path.join(testOutput, 'dbtest', 'config'))).toBe(true); }); + + it('generates an OpenAPI spec and README docs when --openapi is used', () => { + runCLI('-n openapitest -f express --openapi -p 8080 --skip-install'); + + const appDir = path.join(testOutput, 'openapitest'); + const specPath = path.join(appDir, 'docs', 'openapi.yaml'); + const readme = fs.readFileSync(path.join(appDir, 'README.md'), 'utf-8'); + const spec = fs.readFileSync(specPath, 'utf-8'); + + expect(fs.existsSync(specPath)).toBe(true); + expect(spec).toContain('openapi: 3.0.3'); + expect(spec).toContain("title: 'openapitest API'"); + expect(spec).toContain('url: http://localhost:8080'); + expect(spec).toContain('operationId: getRoot'); + expect(spec).toContain('operationId: postRoot'); + expect(spec).toContain('operationId: getHealth'); + expect(spec).toContain('NotFound:'); + expect(spec).toContain('InternalServerError:'); + expect(readme).toContain('docs/openapi.yaml'); + }); + + it('does not generate an OpenAPI spec by default', () => { + runCLI('-n noopenapi -f express --skip-install'); + + expect( + fs.existsSync(path.join(testOutput, 'noopenapi', 'docs', 'openapi.yaml')) + ).toBe(false); + }); }); describe('Node app generation', () => { @@ -283,6 +312,14 @@ describe('CLI Integration', () => { expect(fs.existsSync(path.join(testOutput, 'nodeview'))).toBe(false); }); + it('rejects --openapi with the node framework', () => { + expectCLIError( + '-n nodeopenapi -f node --openapi --skip-install', + 'only supported with the express framework' + ); + expect(fs.existsSync(path.join(testOutput, 'nodeopenapi'))).toBe(false); + }); + it('does not create a views directory for node apps', () => { runCLI('-n nodenoview -f node --skip-install'); expect(fs.existsSync(path.join(testOutput, 'nodenoview', 'views'))).toBe(false); diff --git a/tests/integration/packaged.test.js b/tests/integration/packaged.test.js index 5e0d26a..6ffe639 100644 --- a/tests/integration/packaged.test.js +++ b/tests/integration/packaged.test.js @@ -62,9 +62,9 @@ describe('packaged CLI (from npm tarball)', () => { if (workDir) fs.removeSync(workDir); }); - const generateFromTarball = (name, framework) => { + const generateFromTarball = (name, framework, extraArgs = '') => { execSync( - `node "${extractedBin}" -n ${name} -f ${framework} --skip-install`, + `node "${extractedBin}" -n ${name} -f ${framework} ${extraArgs} --skip-install`, { cwd: workDir, encoding: 'utf-8', @@ -85,6 +85,16 @@ describe('packaged CLI (from npm tarball)', () => { expect(contents).toContain('node_modules'); }, 120000); + it('generates OpenAPI docs from the packaged CLI', () => { + const appDir = generateFromTarball('pack-openapi', 'express', '--openapi -p 8080'); + const specPath = path.join(appDir, 'docs', 'openapi.yaml'); + const spec = fs.readFileSync(specPath, 'utf-8'); + + expect(fs.existsSync(specPath)).toBe(true); + expect(spec).toContain("title: 'pack-openapi API'"); + expect(spec).toContain('url: http://localhost:8080'); + }, 120000); + it('ships a real .gitignore in a generated node app', () => { const appDir = generateFromTarball('pack-test-node', 'node'); diff --git a/tests/unit/app_generator.test.js b/tests/unit/app_generator.test.js index 063fae1..63743ed 100644 --- a/tests/unit/app_generator.test.js +++ b/tests/unit/app_generator.test.js @@ -20,6 +20,7 @@ function makeDeps() { addGitIgnore: vi.fn(), addDockerSupport: vi.fn(), addReadme: vi.fn(), + addOpenApiSpec: vi.fn(), addEnvExample: vi.fn(), }, displayer: { beginMessage: vi.fn(), endMessage: vi.fn() }, @@ -89,6 +90,11 @@ describe('AppGenerator', () => { expect(gen.skipInstall).toBe(false); }); + it('defaults openapi to false when not provided', () => { + const gen = new AppGenerator({ appName: 'myapp', config }, deps); + expect(gen.openapi).toBe(false); + }); + it('computes folderDir as cwd/appName', () => { const gen = new AppGenerator({ appName: 'myapp', config }, deps); expect(gen.folderDir).toBe(path.join(testDir, 'myapp')); @@ -137,6 +143,7 @@ describe('AppGenerator', () => { expect(deps.fileCreator.addGitIgnore).toHaveBeenCalledTimes(1); expect(deps.fileCreator.addDockerSupport).toHaveBeenCalledTimes(1); expect(deps.fileCreator.addReadme).toHaveBeenCalledTimes(1); + expect(deps.fileCreator.addOpenApiSpec).not.toHaveBeenCalled(); expect(deps.fileCreator.addEnvExample).toHaveBeenCalledTimes(1); expect(deps.logger.success).toHaveBeenCalledWith( 'Application myapp created successfully' @@ -343,18 +350,60 @@ describe('AppGenerator', () => { expect(deps.fileCreator.addDockerSupport).toHaveBeenCalledWith( folderDir, templatesDir, - { db: false, port: 3000 } + { db: false, openapi: false, port: 3000 } ); expect(deps.fileCreator.addReadme).toHaveBeenCalledWith( folderDir, templatesDir, - { appName: 'myapp', db: false, port: 3000 } + { appName: 'myapp', db: false, openapi: false, port: 3000 } ); expect(deps.fileCreator.addEnvExample).toHaveBeenCalledWith( folderDir, templatesDir, - { db: false, port: 3000 } + { db: false, openapi: false, port: 3000 } + ); + }); + + it('adds an OpenAPI spec only for express apps when requested', () => { + const gen = new AppGenerator( + { + appName: 'myapp', + framework: 'express', + openapi: true, + port: 8080, + view: 'ejs', + config, + }, + deps ); + + gen.addSupportFiles(); + + expect(deps.fileCreator.addOpenApiSpec).toHaveBeenCalledWith( + path.join(testDir, 'myapp'), + { appName: 'myapp', port: 8080, view: 'ejs' } + ); + expect(deps.fileCreator.addReadme).toHaveBeenCalledWith( + path.join(testDir, 'myapp'), + config.paths.templates.express, + { appName: 'myapp', db: false, openapi: true, port: 8080 } + ); + }); + + it('does not add an OpenAPI spec for node apps', () => { + const gen = new AppGenerator( + { + appName: 'nodeapp', + framework: 'node', + openapi: true, + config, + }, + deps + ); + + gen.addSupportFiles(); + + expect(deps.fileCreator.addOpenApiSpec).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/file_generator.test.js b/tests/unit/file_generator.test.js index 40fa8b2..96afb42 100644 --- a/tests/unit/file_generator.test.js +++ b/tests/unit/file_generator.test.js @@ -11,6 +11,7 @@ import { addGitIgnore, addDockerSupport, addReadme, + addOpenApiSpec, addEnvExample, } from '../../lib/file_generator.js'; import { VIEW_ENGINES, DEPENDENCY_VERSIONS } from '../../lib/constants.js'; @@ -409,6 +410,14 @@ describe('file_generator', () => { expect(readme).not.toContain('# Project Name'); }); + it('documents the OpenAPI spec when requested', () => { + addReadme(testDir, templatesDir, { openapi: true }); + const readme = fs.readFileSync(path.join(testDir, 'README.md'), 'utf-8'); + + expect(readme).toContain('## OpenAPI Specification'); + expect(readme).toContain('docs/openapi.yaml'); + }); + it('updates Node README localhost URLs when configured', () => { addReadme(testDir, nodeTemplatesDir, { port: 8081 }); const readme = fs.readFileSync(path.join(testDir, 'README.md'), 'utf-8'); @@ -434,6 +443,36 @@ describe('file_generator', () => { }); }); + describe('addOpenApiSpec', () => { + it('writes docs/openapi.yaml with app name, port, and route docs', () => { + addOpenApiSpec(testDir, { appName: 'my-api', port: 8080 }); + const specPath = path.join(testDir, 'docs', 'openapi.yaml'); + const spec = fs.readFileSync(specPath, 'utf-8'); + + expect(fs.existsSync(specPath)).toBe(true); + expect(spec).toContain('openapi: 3.0.3'); + expect(spec).toContain("title: 'my-api API'"); + expect(spec).toContain('url: http://localhost:8080'); + expect(spec).toContain('operationId: getRoot'); + expect(spec).toContain('operationId: postRoot'); + expect(spec).toContain('operationId: getHealth'); + expect(spec).toContain("NotFound:"); + expect(spec).toContain("InternalServerError:"); + }); + + it('documents rendered HTML for root GET when a view is selected', () => { + addOpenApiSpec(testDir, { appName: 'view-api', port: 3000, view: 'pug' }); + const spec = fs.readFileSync( + path.join(testDir, 'docs', 'openapi.yaml'), + 'utf-8' + ); + + expect(spec).toContain('text/html:'); + expect(spec).toContain('Rendered welcome page'); + expect(spec).not.toContain('$ref: \'#/components/schemas/WelcomeResponse\''); + }); + }); + describe('addEnvExample', () => { it('copies the .env.example when present', () => { addEnvExample(testDir, templatesDir); diff --git a/tests/unit/validator.test.js b/tests/unit/validator.test.js index 32fc61f..c5e468a 100644 --- a/tests/unit/validator.test.js +++ b/tests/unit/validator.test.js @@ -135,6 +135,23 @@ describe('validateOptions', () => { ); expect(result.isValid).toBe(true); }); + + it('rejects --openapi with the node framework', () => { + const result = validateOptions( + { framework: 'node', openapi: true }, + validationRules + ); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('--openapi option'))).toBe(true); + }); + + it('allows --openapi with the express framework', () => { + const result = validateOptions( + { framework: 'express', openapi: true }, + validationRules + ); + expect(result.isValid).toBe(true); + }); }); describe('combined validation', () => {