diff --git a/.gitignore b/.gitignore index 760e0be38..ef229ed3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ coverage/ dist/ node_modules/ -swagger.json diff --git a/README.md b/README.md index dd2030725..a1773e486 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ ### Setup +> **Note** > These setup instructions are for CLI usage only. For usage in GitHub Actions, see [GitHub Actions](#github-actions) below. We recommend installing `rdme` in your project rather than doing a global installation so you don't run into unexpected behavior with mismatching versions. We also suggest using the `--save-dev` flag since `rdme` is typically used as part of a CI process and is unlikely to be running in your production application: @@ -55,6 +56,7 @@ If you wish to get more information about any command within `rdme`, you can exe ### GitHub Actions +> **Note** > For a full GitHub Workflow file example and additional information on GitHub Actions usage, check out [our docs](https://docs.readme.com/docs/rdme#github-actions-usage). For usage in [GitHub Actions](https://docs.github.com/actions), create [a new GitHub Workflow file](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions) in the `.github/workflows` directory of your repository and add the following [steps](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsteps) to your workflow: @@ -86,9 +88,10 @@ Note that the `@XX` in the above examples refers to the version of `rdme`. You c ReadMe supports [OpenAPI 3.1](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md), [OpenAPI 3.0](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md), and [Swagger 2.x](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/2.0.md). -The following examples use JSON files, but we support API Definitions that are written in either JSON or YAML. +The following examples use JSON files, but `rdme` supports API Definitions that are written in either JSON or YAML. -> ℹ️ Note that the `rdme openapi` command supports both OpenAPI and Swagger API definitions. The `rdme swagger` command is an alias for `rdme openapi` and is deprecated. +> **Note** +> The `rdme openapi` command supports both OpenAPI and Swagger API definitions. The `rdme swagger` command is an alias for `rdme openapi` and is deprecated. #### Uploading a New API Definition to ReadMe @@ -132,14 +135,16 @@ rdme openapi [path-to-file.json] --useSpecVersion #### Omitting the File Path -If you run `rdme` within a directory that contains your OpenAPI or Swagger definition, you can omit the file path. We will then look for a file with the following names, and upload that: +If you run `rdme` within a directory that contains your OpenAPI or Swagger definition, you can omit the file path. `rdme` will then look for JSON or YAML files (including in sub-directories) that contain a top-level [`openapi`](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#fixed-fields) or [`swagger`](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/2.0.md#fixed-fields) property. -- `openapi.json` -- `openapi.yaml` -- `openapi.yml` -- `swagger.json` -- `swagger.yaml` -- `swagger.yml` + + +> **Note** +> `rdme` will not scan anything in the following: +> +> - Any `.git/` directories (if they exist) +> - Any files/directories specified in `.gitignore` files (including any `.gitignore` files in subdirectories, if they exist) + ```sh rdme openapi @@ -161,6 +166,8 @@ You can also perform a local validation of your API definition without uploading rdme validate [path-to-file.json] ``` +Similar to the `openapi` command, you can also [omit the file path](#omitting-the-file-path). + ### Docs #### Syncing a Folder of Markdown Docs to ReadMe diff --git a/__tests__/__fixtures__/invalid-json/yikes.json b/__tests__/__fixtures__/invalid-json/yikes.json new file mode 100644 index 000000000..c3d84c9b5 --- /dev/null +++ b/__tests__/__fixtures__/invalid-json/yikes.json @@ -0,0 +1 @@ +{ "some invalid json that is not valid at all": diff --git a/__tests__/__fixtures__/nested-gitignored-oas/nest/.gitignore b/__tests__/__fixtures__/nested-gitignored-oas/nest/.gitignore new file mode 100644 index 000000000..9484b13d3 --- /dev/null +++ b/__tests__/__fixtures__/nested-gitignored-oas/nest/.gitignore @@ -0,0 +1,2 @@ +# This fixture is for testing .gitignore files in subdirectories +petstore-ignored.json diff --git a/__tests__/__fixtures__/nested-gitignored-oas/nest/petstore.json b/__tests__/__fixtures__/nested-gitignored-oas/nest/petstore.json new file mode 100644 index 000000000..ab136f5f7 --- /dev/null +++ b/__tests__/__fixtures__/nested-gitignored-oas/nest/petstore.json @@ -0,0 +1,61 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Single Path", + "description": "This is a slimmed down single path version of the Petstore definition." + }, + "servers": [ + { + "url": "https://httpbin.org" + } + ], + "paths": { + "/pet/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "put": { + "tags": ["pet"], + "summary": "Update a pet", + "description": "This operation will update a pet in the database.", + "responses": { + "400": { + "description": "Invalid id value" + } + }, + "security": [ + { + "apiKey": [] + } + ] + }, + "get": { + "tags": ["pet"], + "summary": "Find a pet", + "description": "This operation will find a pet in the database.", + "responses": { + "400": { + "description": "Invalid status value" + } + }, + "security": [] + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "http", + "scheme": "basic" + } + } + } +} diff --git a/__tests__/cmds/openapi.test.ts b/__tests__/cmds/openapi.test.ts index 0c28e69ba..96acc5671 100644 --- a/__tests__/cmds/openapi.test.ts +++ b/__tests__/cmds/openapi.test.ts @@ -1,6 +1,4 @@ /* eslint-disable no-console */ -import fs from 'fs'; - import chalk from 'chalk'; import config from 'config'; import nock from 'nock'; @@ -113,14 +111,11 @@ describe('rdme openapi', () => { const registryUUID = getRandomRegistryId(); const mock = getAPIMock() - .get('/api/v1/version') + .get(`/api/v1/version/${version}`) .basicAuth({ user: key }) .reply(200, [{ version }]) .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) - .post('/api/v1/version') - .basicAuth({ user: key }) - .reply(200, { from: '1.0.1', version: '1.0.1' }) .get('/api/v1/api-specification') .basicAuth({ user: key }) .reply(200, []) @@ -129,19 +124,21 @@ describe('rdme openapi', () => { .basicAuth({ user: key }) .reply(201, { _id: 1 }, { location: exampleRefLocation }); - // Surface our test fixture to the root directory so rdme can autodiscover it. It's easier to do - // this than mocking out the fs module because mocking the fs module here causes Jest sourcemaps - // to break. - fs.copyFileSync(require.resolve('@readme/oas-examples/2.0/json/petstore.json'), './swagger.json'); + const spec = 'petstore.json'; - await expect(openapi.run({ key })).resolves.toBe(successfulUpload('swagger.json', 'Swagger')); + await expect( + openapi.run({ + key, + version, + workingDirectory: './__tests__/__fixtures__/relative-ref-oas', + }) + ).resolves.toBe(successfulUpload(spec)); expect(console.info).toHaveBeenCalledTimes(1); const output = getCommandOutput(); - expect(output).toBe(chalk.yellow('ℹ️ We found swagger.json and are attempting to upload it.')); + expect(output).toBe(chalk.yellow(`ℹ️ We found ${spec} and are attempting to upload it.`)); - fs.unlinkSync('./swagger.json'); return mock.done(); }); @@ -217,6 +214,25 @@ describe('rdme openapi', () => { return mock.done(); }); + + describe('CI spec selection', () => { + beforeEach(() => { + process.env.TEST_CI = 'true'; + }); + + afterEach(() => { + delete process.env.TEST_CI; + }); + + it('should error out if multiple possible spec matches were found', () => { + return expect( + openapi.run({ + key, + version, + }) + ).rejects.toStrictEqual(new Error('Multiple API definitions found in current directory. Please specify file.')); + }); + }); }); describe('updates / resyncs', () => { @@ -477,7 +493,7 @@ describe('rdme openapi', () => { }); it('should error if no file was provided or able to be discovered', () => { - return expect(openapi.run({ key, version })).rejects.toThrow( + return expect(openapi.run({ key, version, workingDirectory: 'config' })).rejects.toThrow( /We couldn't find an OpenAPI or Swagger definition./ ); }); @@ -691,9 +707,8 @@ describe('rdme swagger', () => { }); it('should run `rdme openapi`', () => { - return expect(swagger.run({ spec: '', key, id, version })).rejects.toThrow( - "We couldn't find an OpenAPI or Swagger definition.\n\n" + - 'Please specify the path to your definition with `rdme openapi ./path/to/api/definition`.' + return expect(swagger.run({ spec: 'some-non-existent-path', key, id, version })).rejects.toThrow( + "ENOENT: no such file or directory, open 'some-non-existent-path'" ); }); }); diff --git a/__tests__/cmds/validate.test.ts b/__tests__/cmds/validate.test.ts index d51fcd67a..b127d1c4e 100644 --- a/__tests__/cmds/validate.test.ts +++ b/__tests__/cmds/validate.test.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import chalk from 'chalk'; +import prompts from 'prompts'; import Command from '../../src/cmds/validate'; @@ -45,23 +46,24 @@ describe('rdme validate', () => { }); it('should discover and upload an API definition if none is provided', async () => { - // Surface our test fixture to the root directory so rdme can autodiscover it. It's easier to do - // this than mocking out the fs module because mocking the fs module here causes Jest sourcemaps - // to break. - fs.copyFileSync(require.resolve('@readme/oas-examples/2.0/json/petstore.json'), './swagger.json'); - - await expect(validate.run({})).resolves.toBe(chalk.green('swagger.json is a valid Swagger API definition!')); + await expect(validate.run({ workingDirectory: './__tests__/__fixtures__/relative-ref-oas' })).resolves.toBe( + chalk.green('petstore.json is a valid OpenAPI API definition!') + ); expect(console.info).toHaveBeenCalledTimes(1); const output = getCommandOutput(); - expect(output).toBe(chalk.yellow('ℹ️ We found swagger.json and are attempting to validate it.')); + expect(output).toBe(chalk.yellow('ℹ️ We found petstore.json and are attempting to validate it.')); + }); - fs.unlinkSync('./swagger.json'); + it('should select spec in prompt and validate it', async () => { + const spec = '__tests__/__fixtures__/petstore-simple-weird-version.json'; + prompts.inject([spec]); + await expect(validate.run({})).resolves.toBe(chalk.green(`${spec} is a valid OpenAPI API definition!`)); }); - it('should use specified working directory', async () => { - await expect( + it('should use specified working directory', () => { + return expect( validate.run({ spec: 'petstore.json', workingDirectory: './__tests__/__fixtures__/relative-ref-oas', @@ -69,7 +71,26 @@ describe('rdme validate', () => { ).resolves.toBe(chalk.green('petstore.json is a valid OpenAPI API definition!')); }); + it('should adhere to .gitignore in subdirectories', () => { + fs.copyFileSync( + require.resolve('@readme/oas-examples/3.0/json/petstore-simple.json'), + './__tests__/__fixtures__/nested-gitignored-oas/nest/petstore-ignored.json' + ); + + return expect( + validate.run({ + workingDirectory: './__tests__/__fixtures__/nested-gitignored-oas', + }) + ).resolves.toBe(chalk.green('nest/petstore.json is a valid OpenAPI API definition!')); + }); + describe('error handling', () => { + it('should throw an error if invalid JSON is supplied', () => { + return expect(validate.run({ spec: './__tests__/__fixtures__/invalid-json/yikes.json' })).rejects.toStrictEqual( + new SyntaxError('Unexpected end of JSON input') + ); + }); + it('should throw an error if an invalid OpenAPI 3.0 definition is supplied', () => { return expect(validate.run({ spec: './__tests__/__fixtures__/invalid-oas.json' })).rejects.toThrow( 'Token "Error" does not exist.' diff --git a/package-lock.json b/package-lock.json index a1a06312f..7ae1203ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "enquirer": "^2.3.0", "form-data": "^4.0.0", "gray-matter": "^4.0.1", + "ignore": "^5.2.0", "isemail": "^3.1.3", "mime-types": "^2.1.35", "node-fetch": "^2.6.1", @@ -29,6 +30,7 @@ "open": "^8.2.1", "ora": "^5.4.1", "parse-link-header": "^2.0.0", + "prompts": "^2.4.2", "read": "^1.0.7", "semver": "^7.0.0", "tmp-promise": "^3.0.2", @@ -49,6 +51,7 @@ "@types/node-fetch": "^2.6.2", "@types/npmcli__ci-detect": "^2.0.0", "@types/parse-link-header": "^2.0.0", + "@types/prompts": "^2.0.14", "@types/read": "^0.0.29", "@types/semver": "^7.3.10", "@types/tmp": "^0.2.3", @@ -1903,6 +1906,15 @@ "integrity": "sha512-fOwvpvQYStpb/zHMx0Cauwywu9yLDmzWiiQBC7gJyq5tYLUXFZvDG7VK1B7WBxxjBJNKFOZ0zLoOQn8vmATbhw==", "dev": true }, + "node_modules/@types/prompts": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.0.14.tgz", + "integrity": "sha512-HZBd99fKxRWpYCErtm2/yxUZv6/PBI9J7N4TNFffl5JbrYMHBwF25DjQGTW3b3jmXq+9P6/8fCIb2ee57BFfYA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/read": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/read/-/read-0.0.29.tgz", @@ -5892,7 +5904,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", - "dev": true, "engines": { "node": ">= 4" } @@ -7605,7 +7616,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, "engines": { "node": ">=6" } @@ -10106,7 +10116,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" @@ -11047,8 +11056,7 @@ "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" }, "node_modules/slash": { "version": "3.0.0", @@ -14317,6 +14325,15 @@ "integrity": "sha512-fOwvpvQYStpb/zHMx0Cauwywu9yLDmzWiiQBC7gJyq5tYLUXFZvDG7VK1B7WBxxjBJNKFOZ0zLoOQn8vmATbhw==", "dev": true }, + "@types/prompts": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.0.14.tgz", + "integrity": "sha512-HZBd99fKxRWpYCErtm2/yxUZv6/PBI9J7N4TNFffl5JbrYMHBwF25DjQGTW3b3jmXq+9P6/8fCIb2ee57BFfYA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/read": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/read/-/read-0.0.29.tgz", @@ -17166,8 +17183,7 @@ "ignore": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", - "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", - "dev": true + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==" }, "import-fresh": { "version": "3.3.0", @@ -18412,8 +18428,7 @@ "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==" }, "language-subtag-registry": { "version": "0.3.21", @@ -20182,7 +20197,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, "requires": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" @@ -20886,8 +20900,7 @@ "sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" }, "slash": { "version": "3.0.0", diff --git a/package.json b/package.json index b0de2d4fc..6315f8a90 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "enquirer": "^2.3.0", "form-data": "^4.0.0", "gray-matter": "^4.0.1", + "ignore": "^5.2.0", "isemail": "^3.1.3", "mime-types": "^2.1.35", "node-fetch": "^2.6.1", @@ -54,6 +55,7 @@ "open": "^8.2.1", "ora": "^5.4.1", "parse-link-header": "^2.0.0", + "prompts": "^2.4.2", "read": "^1.0.7", "semver": "^7.0.0", "tmp-promise": "^3.0.2", @@ -71,6 +73,7 @@ "@types/node-fetch": "^2.6.2", "@types/npmcli__ci-detect": "^2.0.0", "@types/parse-link-header": "^2.0.0", + "@types/prompts": "^2.0.14", "@types/read": "^0.0.29", "@types/semver": "^7.3.10", "@types/tmp": "^0.2.3", diff --git a/src/cmds/changelogs/index.ts b/src/cmds/changelogs/index.ts index 0453f3855..9338d0e8c 100644 --- a/src/cmds/changelogs/index.ts +++ b/src/cmds/changelogs/index.ts @@ -4,7 +4,8 @@ import chalk from 'chalk'; import config from 'config'; import Command, { CommandCategories } from '../../lib/baseCommand'; -import pushDoc, { readdirRecursive } from '../../lib/pushDoc'; +import pushDoc from '../../lib/pushDoc'; +import readdirRecursive from '../../lib/readdirRecursive'; export type Options = { dryRun?: boolean; diff --git a/src/cmds/custompages/index.ts b/src/cmds/custompages/index.ts index 9ae45089a..9e78add83 100644 --- a/src/cmds/custompages/index.ts +++ b/src/cmds/custompages/index.ts @@ -4,7 +4,8 @@ import chalk from 'chalk'; import config from 'config'; import Command, { CommandCategories } from '../../lib/baseCommand'; -import pushDoc, { readdirRecursive } from '../../lib/pushDoc'; +import pushDoc from '../../lib/pushDoc'; +import readdirRecursive from '../../lib/readdirRecursive'; export type Options = { dryRun?: boolean; diff --git a/src/cmds/docs/index.ts b/src/cmds/docs/index.ts index 1bc649e85..c86058ae8 100644 --- a/src/cmds/docs/index.ts +++ b/src/cmds/docs/index.ts @@ -4,7 +4,8 @@ import chalk from 'chalk'; import config from 'config'; import Command, { CommandCategories } from '../../lib/baseCommand'; -import pushDoc, { readdirRecursive } from '../../lib/pushDoc'; +import pushDoc from '../../lib/pushDoc'; +import readdirRecursive from '../../lib/readdirRecursive'; import { getProjectVersion } from '../../lib/versionSelect'; export type Options = { diff --git a/src/lib/prepareOas.ts b/src/lib/prepareOas.ts index d874baace..ccdacb0d4 100644 --- a/src/lib/prepareOas.ts +++ b/src/lib/prepareOas.ts @@ -1,45 +1,107 @@ -import fs from 'fs'; - +import ciDetect from '@npmcli/ci-detect'; import chalk from 'chalk'; import OASNormalize from 'oas-normalize'; import ora from 'ora'; +import prompts from 'prompts'; import { debug, info, oraOptions } from './logger'; +import readdirRecursive from './readdirRecursive'; + +type FileSelection = { + file: string; +}; /** * Normalizes, validates, and (optionally) bundles an OpenAPI definition. * * @param {String} path path to spec file. if this is missing, the current directory is searched * for certain file names - * @param {('openapi'|'validate')} command string to distinguish if it's being run in + * @param command string to distinguish if it's being run in * an 'openapi' or 'validate' context */ export default async function prepareOas(path: string, command: 'openapi' | 'validate') { let specPath = path; if (!specPath) { - // If the user didn't supply an API specification, let's try to locate what they've got, and validate that. If they - // don't have any, let's let the user know how they can get one going. - specPath = await new Promise((resolve, reject) => { - ['swagger.json', 'swagger.yaml', 'swagger.yml', 'openapi.json', 'openapi.yaml', 'openapi.yml'].forEach(file => { - debug(`looking for definition with filename: ${file}`); - if (!fs.existsSync(file)) { - debug(`${file} not found`); - return; - } - - info( - chalk.yellow(`We found ${file} and are attempting to ${command === 'openapi' ? 'upload' : 'validate'} it.`) - ); - resolve(file); + /** + * Scans working directory for a potential OpenAPI or Swagger file. + * Any files in the `.git` directory or defined in a top-level `.gitignore` file + * are skipped. + * + * A "potential OpenAPI or Swagger file" is defined as a YAML or JSON file + * that has an `openapi` or `swagger` property defined at the top-level. + * + * If multiple potential files are found, the user must select a single file. + * + * An error is thrown in the following cases: + * - if in a CI environment and multiple files are found + * - no files are found + */ + + const fileFindingSpinner = ora({ text: 'Attempting to locate API definitions...', ...oraOptions() }).start(); + + const action = command === 'openapi' ? 'upload' : 'validate'; + + const jsonAndYamlFiles = readdirRecursive('.', true).filter( + file => + file.toLowerCase().endsWith('.json') || + file.toLowerCase().endsWith('.yaml') || + file.toLowerCase().endsWith('.yml') + ); + + debug(`number of JSON or YAML files found: ${jsonAndYamlFiles.length}`); + + const possibleSpecFiles = ( + await Promise.all( + jsonAndYamlFiles.map(file => { + debug(`attempting to oas-normalize ${file}`); + const oas = new OASNormalize(file, { enablePaths: true }); + return oas + .version() + .then(version => { + debug(`OpenAPI/Swagger version for ${file}: ${version}`); + return version ? file : ''; + }) + .catch(e => { + debug(`error extracting OpenAPI/Swagger version for ${file}: ${e.message}`); + return ''; + }); + }) + ) + ).filter(Boolean); + + debug(`number of possible OpenAPI/Swagger files found: ${possibleSpecFiles.length}`); + + if (!possibleSpecFiles.length) { + fileFindingSpinner.fail(); + throw new Error( + `We couldn't find an OpenAPI or Swagger definition.\n\nPlease specify the path to your definition with \`rdme ${command} ./path/to/api/definition\`.` + ); + } + + specPath = possibleSpecFiles[0]; + + if (possibleSpecFiles.length === 1) { + fileFindingSpinner.succeed(`${fileFindingSpinner.text} found! 🔍`); + info(chalk.yellow(`We found ${specPath} and are attempting to ${action} it.`)); + } else if (possibleSpecFiles.length > 1) { + /* istanbul ignore next */ + if ((ciDetect() && process.env.NODE_ENV !== 'testing') || process.env.TEST_CI) { + fileFindingSpinner.fail(); + throw new Error('Multiple API definitions found in current directory. Please specify file.'); + } + + fileFindingSpinner.succeed(`${fileFindingSpinner.text} found! 🔍`); + + const selection: FileSelection = await prompts({ + name: 'file', + message: `Multiple potential API definitions found! Which file would you like to ${action}?`, + type: 'select', + choices: possibleSpecFiles.map(file => ({ title: file, value: file })), }); - reject( - new Error( - `We couldn't find an OpenAPI or Swagger definition.\n\nPlease specify the path to your definition with \`rdme ${command} ./path/to/api/definition\`.` - ) - ); - }); + specPath = selection.file; + } } const spinner = ora({ text: `Validating API definition located at ${specPath}...`, ...oraOptions() }).start(); @@ -62,7 +124,8 @@ export default async function prepareOas(path: string, command: 'openapi' | 'val const specType = api.swagger ? 'Swagger' : 'OpenAPI'; debug(`spec type: ${specType}`); - const specVersion = api?.info?.version; + // No need to optional chain here since `info.version` is required to pass validation + const specVersion = api.info.version; debug(`version in spec: ${specVersion}`); let bundledSpec = ''; diff --git a/src/lib/pushDoc.ts b/src/lib/pushDoc.ts index aafda83e2..91443d549 100644 --- a/src/lib/pushDoc.ts +++ b/src/lib/pushDoc.ts @@ -139,21 +139,3 @@ export default async function pushDoc( throw err; }); } - -/** - * Recursively grabs all files within a given directory - * (including subdirectories) - * @param {String} folderToSearch path to directory - * @returns {String[]} array of files - */ -export function readdirRecursive(folderToSearch: string): string[] { - const filesInFolder = fs.readdirSync(folderToSearch, { withFileTypes: true }); - const files = filesInFolder - .filter(fileHandle => fileHandle.isFile()) - .map(fileHandle => path.join(folderToSearch, fileHandle.name)); - const folders = filesInFolder.filter(fileHandle => fileHandle.isDirectory()); - const subFiles = [].concat( - ...folders.map(fileHandle => readdirRecursive(path.join(folderToSearch, fileHandle.name))) - ); - return [...files, ...subFiles]; -} diff --git a/src/lib/readdirRecursive.ts b/src/lib/readdirRecursive.ts new file mode 100644 index 000000000..1e27cddfe --- /dev/null +++ b/src/lib/readdirRecursive.ts @@ -0,0 +1,53 @@ +import type { Ignore } from 'ignore'; + +import fs from 'fs'; +import path from 'path'; + +import ignore from 'ignore'; + +import { debug } from './logger'; + +/** + * Recursively grabs all files within a given directory + * (including subdirectories) + * @param folderToSearch path to directory + * @param ignoreGit boolean to indicate whether or not to ignore + * `.git` directory plus any files specified in `.gitignore` + * @returns array of file names + */ +export default function readdirRecursive(folderToSearch: string, ignoreGit = false): string[] { + debug(`current readdirRecursive folder: ${folderToSearch}`); + + let ignoreFilter: Ignore; + if (ignoreGit) { + // Initialize ignore filter with `.git` directory + ignoreFilter = ignore().add(path.join(folderToSearch, '.git/')); + // If .gitignore file exists, load its contents into ignore filter + if (fs.existsSync(path.join(folderToSearch, '.gitignore'))) { + debug('.gitignore file found, adding to ignore filter'); + ignoreFilter.add(fs.readFileSync(path.join(folderToSearch, '.gitignore')).toString()); + } + } + + const filesInFolder = fs.readdirSync(folderToSearch, { withFileTypes: true }).filter(item => { + if (!ignoreGit) return true; + // Some logic to construct pathname the way that `ignore` package consumes it + // https://github.com/kaelzhang/node-ignore#2-filenames-and-dirnames + let fullPathName = path.join(folderToSearch, item.name); + if (item.isDirectory()) fullPathName = `${fullPathName}/`; + + return !ignoreFilter.ignores(fullPathName); + }); + + const files = filesInFolder + .filter(fileHandle => fileHandle.isFile()) + .map(fileHandle => path.join(folderToSearch, fileHandle.name)); + + const folders = filesInFolder.filter(fileHandle => fileHandle.isDirectory()); + + const subFiles = [].concat( + ...folders.map(fileHandle => readdirRecursive(path.join(folderToSearch, fileHandle.name), ignoreGit)) + ); + + return [...files, ...subFiles]; +}