From 8488d2e8d4049fb5442f7ef58b71332b99e4f659 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Wed, 11 Dec 2024 12:39:21 -0600 Subject: [PATCH] feat!: remove `openapi` v1 command (#1111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🧰 Changes Removes the `openapi` command in its current form. Will be replaced by `openapi upload` (see https://github.com/readmeio/rdme/pull/1116!) Will be merging this PR into https://github.com/readmeio/rdme/pull/1113 to collect all breaking changes as part of this PR! ## 🧬 QA & Testing Provide as much information as you can on how to test what you've done. ## ⚠️ breaking changes BREAKING CHANGE: `rdme openapi` has been removed. Please use `rdme openapi upload` instead. Read more in [our migration guide](https://github.com/readmeio/rdme/tree/v10/documentation/migration-guide.md). --- .github/workflows/ci.yml | 11 - __tests__/commands/openapi/index.test.ts | 1441 ----------------- __tests__/helpers/get-api-mock.ts | 8 +- .../lib/__snapshots__/createGHA.test.ts.snap | 82 - __tests__/lib/createGHA.test.ts | 1 - __tests__/lib/prompts.test.ts | 70 - documentation/commands/openapi.md | 104 -- package-lock.json | 38 +- package.json | 3 - src/commands/openapi/index.ts | 353 ---- src/index.ts | 2 - src/lib/apiError.ts | 2 +- src/lib/config.ts | 4 +- src/lib/flags.ts | 9 - src/lib/prompts.ts | 132 -- src/lib/readmeAPIFetch.ts | 2 +- src/lib/streamSpecToRegistry.ts | 57 - src/lib/versionSelect.ts | 80 - 18 files changed, 7 insertions(+), 2392 deletions(-) delete mode 100644 __tests__/commands/openapi/index.test.ts delete mode 100644 __tests__/lib/prompts.test.ts delete mode 100644 src/commands/openapi/index.ts delete mode 100644 src/lib/prompts.ts delete mode 100644 src/lib/streamSpecToRegistry.ts delete mode 100644 src/lib/versionSelect.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3135c2f7..46011ec01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,17 +103,6 @@ jobs: if: ${{ steps.openapi-validate-fail.outcome == 'success' }} run: echo "::error::Expected validation in previous step to fail" && exit 1 - # Docs: https://rdme-test.readme.io - - name: Run `openapi` command - uses: ./rdme-repo/ - with: - rdme: openapi oas-examples-repo/3.1/json/petstore.json --key=${{ secrets.RDME_TEST_PROJECT_API_KEY }} --id=${{ secrets.RDME_TEST_PROJECT_API_SETTING }} - - - name: Run `openapi` command with weird arg syntax - uses: ./rdme-repo/ - with: - rdme: openapi "oas-examples-repo/3.1/json/petstore.json" --key "${{ secrets.RDME_TEST_PROJECT_API_KEY }}" --id=${{ secrets.RDME_TEST_PROJECT_API_SETTING }} - # this is a test to ensure that the rdme github action can run properly # the way that our users invoke it - name: E2E run of `openapi validate` on `next` branch diff --git a/__tests__/commands/openapi/index.test.ts b/__tests__/commands/openapi/index.test.ts deleted file mode 100644 index d013e4aa7..000000000 --- a/__tests__/commands/openapi/index.test.ts +++ /dev/null @@ -1,1441 +0,0 @@ -/* eslint-disable no-console */ - -import fs from 'node:fs'; - -import chalk from 'chalk'; -import nock from 'nock'; -import prompts from 'prompts'; -import { describe, beforeAll, beforeEach, afterEach, it, expect, vi, type MockInstance } from 'vitest'; - -import Command from '../../../src/commands/openapi/index.js'; -import { APIv1Error } from '../../../src/lib/apiError.js'; -import config from '../../../src/lib/config.js'; -import petstoreWeird from '../../__fixtures__/petstore-simple-weird-version.json' with { type: 'json' }; -import { getAPIv1Mock, getAPIv1MockWithVersionHeader } from '../../helpers/get-api-mock.js'; -import { after, before } from '../../helpers/get-gha-setup.js'; -import { runCommandAndReturnResult } from '../../helpers/oclif.js'; -import { after as afterGHAEnv, before as beforeGHAEnv } from '../../helpers/setup-gha-env.js'; - -let consoleInfoSpy: MockInstance; -let consoleWarnSpy: MockInstance; - -const key = 'API_KEY'; -const id = '5aa0409b7cf527a93bfb44df'; -const version = '1.0.0'; -const exampleRefLocation = `${config.host}/project/example-project/1.0.1/refs/ex`; -const successfulMessageBase = (specPath, specType) => [ - '', - `\t${chalk.green(exampleRefLocation)}`, - '', - `To update your ${specType} definition, run the following:`, - '', - `\t${chalk.green(`rdme openapi ${specPath} --key= --id=1`)}`, -]; -const successfulUpload = (specPath, specType = 'OpenAPI') => - [ - `You've successfully uploaded a new ${specType} file to your ReadMe project!`, - ...successfulMessageBase(specPath, specType), - ].join('\n'); - -const successfulUpdate = (specPath, specType = 'OpenAPI') => - [ - `You've successfully updated an existing ${specType} file on your ReadMe project!`, - ...successfulMessageBase(specPath, specType), - ].join('\n'); - -const getCommandOutput = () => { - return [consoleWarnSpy.mock.calls.join('\n\n'), consoleInfoSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n'); -}; - -const getRandomRegistryId = () => Math.random().toString(36).substring(2); - -describe('rdme openapi', () => { - let run: (args?: string[]) => Promise; - let testWorkingDir: string; - - beforeAll(() => { - nock.disableNetConnect(); - run = runCommandAndReturnResult(Command); - }); - - beforeEach(() => { - consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - testWorkingDir = process.cwd(); - }); - - afterEach(() => { - consoleInfoSpy.mockRestore(); - consoleWarnSpy.mockRestore(); - - process.chdir(testWorkingDir); - - nock.cleanAll(); - }); - - describe('upload', () => { - it.each([ - ['Swagger 2.0', 'json', '2.0', 'Swagger'], - ['Swagger 2.0', 'yaml', '2.0', 'Swagger'], - ['OpenAPI 3.0', 'json', '3.0', 'OpenAPI'], - ['OpenAPI 3.0', 'yaml', '3.0', 'OpenAPI'], - ['OpenAPI 3.1', 'json', '3.1', 'OpenAPI'], - ['OpenAPI 3.1', 'yaml', '3.1', 'OpenAPI'], - - // Postman collections get automatically converted to OpenAPI 3.0 by `oas-normalize`. - ['Postman', 'json', '3.0', 'Postman'], - ['Postman', 'yaml', '3.0', 'Postman'], - ])('should support uploading a %s definition (format: %s)', async (_, format, specVersion, type) => { - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID, spec: { openapi: specVersion } }) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []) - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version: '1.0.0' }); - - const postMock = getAPIv1MockWithVersionHeader(version) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - let spec; - if (type === 'Postman') { - spec = require.resolve(`../../__fixtures__/postman/petstore.collection.${format}`); - } else { - spec = require.resolve(`@readme/oas-examples/${specVersion}/${format}/petstore.${format}`); - } - - await expect(run(['--key', key, '--version', version, spec])).resolves.toBe(successfulUpload(spec, type)); - - expect(console.info).toHaveBeenCalledTimes(0); - - postMock.done(); - return mock.done(); - }); - - it('should create a new spec via prompts', async () => { - prompts.inject(['create']); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .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' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, [{ _id: 'spec1', title: 'spec1_title' }]) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run(['--key', key, '--version', version, spec])).resolves.toBe(successfulUpload(spec)); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should create a new spec via `--create` flag', async () => { - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .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' } }); - - const postMock = getAPIv1MockWithVersionHeader(version) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run(['--key', key, '--version', version, spec, '--create'])).resolves.toBe(successfulUpload(spec)); - - postMock.done(); - return mock.done(); - }); - - it('should create a new spec via `--create` flag and ignore `--id`', async () => { - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .get('/api/v1/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' } }); - - const postMock = getAPIv1MockWithVersionHeader(version) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run(['--key', key, '--id', 'some-id', spec, '--create'])).resolves.toBe(successfulUpload(spec)); - - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.info).toHaveBeenCalledTimes(0); - - const output = getCommandOutput(); - - expect(output).toMatch(/the `--id` parameter will be ignored/i); - - postMock.done(); - return mock.done(); - }); - - it('should bundle and upload the expected content', async () => { - let requestBody; - const registryUUID = getRandomRegistryId(); - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version: '1.0.0' }) - .post('/api/v1/api-registry', body => { - requestBody = body.substring(body.indexOf('{'), body.lastIndexOf('}') + 1); - requestBody = JSON.parse(requestBody); - - return body.match('form-data; name="spec"'); - }) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []); - - const postMock = getAPIv1MockWithVersionHeader(version) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run(['--key', key, '--version', version, spec])).resolves.toBe(successfulUpload(spec)); - - expect(console.info).toHaveBeenCalledTimes(0); - - expect(requestBody).toMatchSnapshot(); - - postMock.done(); - return mock.done(); - }); - - it('should update title, bundle and upload the expected content', async () => { - let requestBody; - const registryUUID = getRandomRegistryId(); - const title = 'some alternative title'; - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version: '1.0.0' }) - .post('/api/v1/api-registry', body => { - requestBody = body.substring(body.indexOf('{'), body.lastIndexOf('}') + 1); - requestBody = JSON.parse(requestBody); - - return body.match('form-data; name="spec"'); - }) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []); - - const postMock = getAPIv1MockWithVersionHeader(version) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run(['--key', key, '--version', version, spec, '--title', title])).resolves.toBe( - successfulUpload(spec), - ); - - expect(console.info).toHaveBeenCalledTimes(0); - - expect(requestBody).toMatchSnapshot(); - - postMock.done(); - return mock.done(); - }); - - it('should upload the expected content and return raw output', async () => { - let requestBody; - const registryUUID = getRandomRegistryId(); - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version: '1.0.0' }) - .post('/api/v1/api-registry', body => { - requestBody = body.substring(body.indexOf('{'), body.lastIndexOf('}') + 1); - requestBody = JSON.parse(requestBody); - - return body.match('form-data; name="spec"'); - }) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []); - - const postMock = getAPIv1MockWithVersionHeader(version) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run(['--key', key, '--version', version, spec, '--raw'])).resolves.toMatchSnapshot(); - - postMock.done(); - return mock.done(); - }); - }); - - describe('updates / resyncs', () => { - it.each([ - ['Swagger 2.0', 'json', '2.0', 'Swagger'], - ['Swagger 2.0', 'yaml', '2.0', 'Swagger'], - ['OpenAPI 3.0', 'json', '3.0', 'OpenAPI'], - ['OpenAPI 3.0', 'yaml', '3.0', 'OpenAPI'], - ['OpenAPI 3.1', 'json', '3.1', 'OpenAPI'], - ['OpenAPI 3.1', 'yaml', '3.1', 'OpenAPI'], - ])('should support updating a %s definition (format: %s)', async (_, format, specVersion, type) => { - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID, spec: { openapi: specVersion } }); - - const putMock = getAPIv1MockWithVersionHeader(version) - .put(`/api/v1/api-specification/${id}`, { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = require.resolve(`@readme/oas-examples/${specVersion}/${format}/petstore.${format}`); - - await expect(run(['--key', key, '--id', id, spec, '--version', version])).resolves.toBe( - successfulUpdate(spec, type), - ); - - putMock.done(); - return mock.done(); - }); - - it('should return warning if providing `id` and `version`', async () => { - expect.assertions(4); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }); - - const putMock = getAPIv1MockWithVersionHeader(version) - .put(`/api/v1/api-specification/${id}`, { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = require.resolve('@readme/oas-examples/3.1/json/petstore.json'); - - await expect(run(['--key', key, '--id', id, spec, '--version', version])).resolves.toBe(successfulUpdate(spec)); - - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.info).toHaveBeenCalledTimes(0); - - const output = getCommandOutput(); - - expect(output).toMatch(/the `--version` option will be ignored/i); - - putMock.done(); - return mock.done(); - }); - - it('should update a spec via prompts', async () => { - prompts.inject(['update', 'spec2']); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .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' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, [ - { _id: 'spec1', title: 'spec1_title' }, - { _id: 'spec2', title: 'spec2_title' }, - ]) - .put('/api/v1/api-specification/spec2', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run(['--key', key, spec, '--version', version])).resolves.toBe(successfulUpdate(spec)); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should discover and upload an API definition if none is provided', async () => { - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }) - .post('/api/v1/api-registry', body => { - return body.match('form-data; name="spec"'); - }) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []); - - const postMock = getAPIv1MockWithVersionHeader(version) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = 'petstore.json'; - - await expect( - run(['--key', key, '--version', 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 ${spec} and are attempting to upload it.`)); - - postMock.done(); - return mock.done(); - }); - - it('should use specified working directory and upload the expected content', async () => { - let requestBody; - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }) - .post('/api/v1/api-registry', body => { - requestBody = body.substring(body.indexOf('{'), body.lastIndexOf('}') + 1); - requestBody = JSON.parse(requestBody); - - return body.match('form-data; name="spec"'); - }) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []); - - const postMock = getAPIv1MockWithVersionHeader(version) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = 'petstore.json'; - - await expect( - run([ - spec, - '--key', - key, - '--version', - version, - '--workingDirectory', - './__tests__/__fixtures__/relative-ref-oas', - ]), - ).resolves.toBe(successfulUpload(spec)); - - expect(console.info).toHaveBeenCalledTimes(0); - - expect(requestBody).toMatchSnapshot(); - - postMock.done(); - return mock.done(); - }); - - it('should return spec update info for dry run', async () => { - prompts.inject(['update', 'spec2']); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .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' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, [ - { _id: 'spec1', title: 'spec1_title' }, - { _id: 'spec2', title: 'spec2_title' }, - ]); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run(['--key', key, spec, '--version', version, '--dryRun'])).resolves.toMatch( - `dry run! The API Definition located at ${spec} will update this API Definition ID: spec2`, - ); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should return spec create info for dry run (with working directory)', async () => { - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }) - .post('/api/v1/api-registry', body => { - return body.match('form-data; name="spec"'); - }) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []); - - await expect( - run([ - '--key', - key, - '--version', - version, - '--workingDirectory', - './__tests__/__fixtures__/relative-ref-oas', - '--dryRun', - ]), - ).resolves.toMatch( - '🎭 dry run! The API Definition located at petstore.json will be created for this project version: 1.0.0', - ); - - const output = getCommandOutput(); - expect(output).toMatch( - chalk.yellow('🎭 dry run option detected! No API definitions will be created or updated in ReadMe.'), - ); - - return mock.done(); - }); - - describe('--update', () => { - it("should update a spec file without prompts if providing `update` and it's the one spec available", async () => { - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .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' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, [{ _id: 'spec1', title: 'spec1_title' }]) - .put('/api/v1/api-specification/spec1', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run(['--key', key, spec, '--version', version, '--update'])).resolves.toBe(successfulUpdate(spec)); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should error if providing `update` and there are multiple specs available', async () => { - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .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' } }) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, [ - { _id: 'spec1', title: 'spec1_title' }, - { _id: 'spec2', title: 'spec2_title' }, - ]); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run(['--key', key, spec, '--version', version, '--update'])).rejects.toStrictEqual( - new Error( - "The `--update` option cannot be used when there's more than one API definition available (found 2).", - ), - ); - return mock.done(); - }); - - it('should warn if providing both `update` and `id`', async () => { - expect.assertions(5); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) - .put('/api/v1/api-specification/spec1', { registryUUID }) - .basicAuth({ user: key }) - .reply(function (uri, rBody, cb) { - expect(this.req.headers['x-readme-version']).toBeUndefined(); - return cb(null, [201, { _id: 1 }, { location: exampleRefLocation }]); - }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run(['--key', key, spec, '--id', 'spec1', '--update'])).resolves.toBe(successfulUpdate(spec)); - - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.info).toHaveBeenCalledTimes(0); - - const output = getCommandOutput(); - expect(output).toMatch(/the `--update` parameter will be ignored./); - return mock.done(); - }); - }); - - it.todo('should paginate to next and previous pages of specs'); - }); - - describe('versioning', () => { - it('should use version from version param properly', async () => { - expect.assertions(2); - let requestBody = ''; - const registryUUID = getRandomRegistryId(); - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version: '1.0.0' }) - .post('/api/v1/api-registry', body => { - requestBody = body.substring(body.indexOf('{'), body.lastIndexOf('}') + 1); - requestBody = JSON.parse(requestBody); - - return body.match('form-data; name="spec"'); - }) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(function (uri, rBody, cb) { - expect(this.req.headers['x-readme-version']).toBe(version); - return cb(null, [201, { _id: 1 }, { location: exampleRefLocation }]); - }); - - const spec = './__tests__/__fixtures__/petstore-simple-weird-version.json'; - - await expect(run(['--key', key, '--version', version, spec])).resolves.toBe(successfulUpload(spec)); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should use version from spec file properly', async () => { - expect.assertions(2); - const specVersion = '1.2.3'; - let requestBody = ''; - const registryUUID = getRandomRegistryId(); - const mock = getAPIv1Mock() - .get(`/api/v1/version/${specVersion}`) - .basicAuth({ user: key }) - .reply(200, { version: specVersion }) - .post('/api/v1/api-registry', body => { - requestBody = body.substring(body.indexOf('{'), body.lastIndexOf('}') + 1); - requestBody = JSON.parse(requestBody); - - return body.match('form-data; name="spec"'); - }) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(specVersion) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(function (uri, rBody, cb) { - expect(this.req.headers['x-readme-version']).toBe(specVersion); - return cb(null, [201, { _id: 1 }, { location: exampleRefLocation }]); - }); - - const spec = './__tests__/__fixtures__/petstore-simple-weird-version.json'; - - await expect(run(['--key', key, spec, '--version', version, '--useSpecVersion'])).resolves.toBe( - successfulUpload(spec), - ); - - mockWithHeader.done(); - return mock.done(); - }); - - describe('CI version handling', () => { - beforeEach(() => { - process.env.TEST_RDME_CI = 'true'; - }); - - afterEach(() => { - delete process.env.TEST_RDME_CI; - }); - - it('should omit version header in CI environment', async () => { - expect.assertions(2); - let requestBody = ''; - const registryUUID = getRandomRegistryId(); - const mock = getAPIv1Mock() - .post('/api/v1/api-registry', body => { - requestBody = body.substring(body.indexOf('{'), body.lastIndexOf('}') + 1); - requestBody = JSON.parse(requestBody); - - return body.match('form-data; name="spec"'); - }) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(function (uri, rBody, cb) { - expect(this.req.headers['x-readme-version']).toBeUndefined(); - return cb(null, [201, { _id: 1 }, { location: exampleRefLocation }]); - }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run(['--key', key, spec])).resolves.toBe(successfulUpload(spec)); - - return mock.done(); - }); - }); - - it('should error if version flag sent to API returns a 404', async () => { - const invalidVersion = 'v1000'; - - const errorObject = { - error: 'VERSION_NOTFOUND', - message: `The version you specified (${invalidVersion}) doesn't match any of the existing versions (1.0) in ReadMe.`, - suggestion: - 'You can pass the version in via the `x-readme-version` header. If you want to create a new version, do so in the Versions section inside ReadMe. Note that the version in the URL is our API version, not the version of your docs.', - docs: 'https://docs.readme.com/logs/xx-xx-xx', - help: "If you need help, email support@readme.io and include the following link to your API log: 'https://docs.readme.com/logs/xx-xx-xx'.", - poem: [ - 'We looked high and low,', - 'Searched up, down and around.', - "You'll have to give it another go,", - `Because version ${invalidVersion}'s not found!`, - ], - }; - - const mock = getAPIv1Mock().get(`/api/v1/version/${invalidVersion}`).reply(404, errorObject); - - await expect( - run([ - '--key', - key, - require.resolve('@readme/oas-examples/3.1/json/petstore.json'), - '--version', - invalidVersion, - ]), - ).rejects.toStrictEqual(new APIv1Error(errorObject)); - - return mock.done(); - }); - - it('should request a version list if version is not found', async () => { - const selectedVersion = '1.0.1'; - prompts.inject([selectedVersion]); - - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .get('/api/v1/version') - .basicAuth({ user: key }) - .reply(200, [{ version: '1.0.0' }, { version: '1.0.1' }]) - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(selectedVersion) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = require.resolve('@readme/oas-examples/2.0/json/petstore.json'); - - await expect(run(['--key', key, spec])).resolves.toBe(successfulUpload(spec, 'Swagger')); - - mockWithHeader.done(); - return mock.done(); - }); - }); - - describe('error handling', () => { - it('should error if `--create` and `--update` flags are passed simultaneously', () => { - return expect(run(['--key', key, '--create', '--update'])).rejects.toThrow( - '--update=true cannot also be provided when using --create', - ); - }); - - it('should error if invalid API key is sent and version list does not load', async () => { - const errorObject = { - error: 'APIKEY_NOTFOUND', - message: "We couldn't find your API key.", - suggestion: - "The API key you passed in (API_KEY) doesn't match any keys we have in our system. API keys must be passed in as the username part of basic auth. You can get your API key in Configuration > API Key, or in the docs.", - docs: 'https://docs.readme.com/logs/xx-xx-xx', - help: "If you need help, email support@readme.io and include the following link to your API log: 'https://docs.readme.com/logs/xx-xx-xx'.", - poem: [ - 'The ancient gatekeeper declares:', - "'To pass, reveal your API key.'", - "'API_KEY', you start to ramble", - 'Oops, you remembered it poorly!', - ], - }; - - const mock = getAPIv1Mock().get('/api/v1/version').reply(401, errorObject); - - await expect( - run([require.resolve('@readme/oas-examples/3.1/json/petstore.json'), '--key', 'key']), - ).rejects.toStrictEqual(new APIv1Error(errorObject)); - - return mock.done(); - }); - - it('should throw an error if an invalid OpenAPI 3.0 definition is supplied', () => { - return expect( - run(['./__tests__/__fixtures__/invalid-oas.json', '--key', key, '--id', id, '--version', version]), - ).rejects.toMatchSnapshot(); - }); - - it('should throw an error if an invalid OpenAPI 3.1 definition is supplied', () => { - return expect( - run(['./__tests__/__fixtures__/invalid-oas-3.1.json', '--key', key, '--id', id, '--version', version]), - ).rejects.toMatchSnapshot(); - }); - - it('should throw an error if an invalid ref is supplied', () => { - return expect( - run(['./__tests__/__fixtures__/invalid-ref-oas/petstore.json', '--key', key, '--id', id, '--version', version]), - ).rejects.toMatchSnapshot(); - }); - - it('should throw an error if an invalid Swagger definition is supplied (create)', async () => { - const errorObject = { - error: 'INTERNAL_ERROR', - message: 'Unknown error (README VALIDATION ERROR "x-samples-languages" must be of type "Array")', - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }; - - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version: '1.0.0' }) - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(400, errorObject); - - await expect( - run(['./__tests__/__fixtures__/swagger-with-invalid-extensions.json', '--key', key, '--version', version]), - ).rejects.toStrictEqual(new APIv1Error(errorObject)); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should throw an error if an invalid Swagger definition is supplied (update)', async () => { - const errorObject = { - error: 'INTERNAL_ERROR', - message: 'Unknown error (README VALIDATION ERROR "x-samples-languages" must be of type "Array")', - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }; - - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }); - - const putMock = getAPIv1MockWithVersionHeader(version) - .put(`/api/v1/api-specification/${id}`, { registryUUID }) - .basicAuth({ user: key }) - .reply(400, errorObject); - - await expect( - run([ - './__tests__/__fixtures__/swagger-with-invalid-extensions.json', - '--key', - key, - '--id', - id, - '--version', - version, - ]), - ).rejects.toStrictEqual(new APIv1Error(errorObject)); - - putMock.done(); - return mock.done(); - }); - - it('should throw an error if registry upload fails', async () => { - const errorObject = { - error: 'INTERNAL_ERROR', - message: 'Unknown error (Registry is offline? lol idk)', - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }; - - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version: '1.0.0' }) - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(400, errorObject); - - await expect( - run(['./__tests__/__fixtures__/swagger-with-invalid-extensions.json', '--key', key, '--version', version]), - ).rejects.toStrictEqual(new APIv1Error(errorObject)); - - return mock.done(); - }); - - it('should error if API errors', async () => { - const errorObject = { - error: 'SPEC_VERSION_NOTFOUND', - message: - "The version you specified ({version}) doesn't match any of the existing versions ({versions_list}) in ReadMe.", - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }; - - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version: '1.0.0' }) - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(400, errorObject); - - await expect( - run([require.resolve('@readme/oas-examples/2.0/json/petstore.json'), '--key', key, '--version', version]), - ).rejects.toStrictEqual(new APIv1Error(errorObject)); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should error if API errors (generic upload error)', async () => { - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version: '1.0.0' }) - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(400, 'some non-JSON upload error'); - - await expect( - run([require.resolve('@readme/oas-examples/2.0/json/petstore.json'), '--key', key, '--version', version]), - ).rejects.toStrictEqual( - new Error( - 'Yikes, something went wrong! Please try uploading your spec again and if the problem persists, get in touch with our support team at support@readme.io.', - ), - ); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should error if API errors (request timeout)', async () => { - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version: '1.0.0' }) - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(500, 'Application Error'); - - await expect( - run([require.resolve('@readme/oas-examples/2.0/json/petstore.json'), '--key', key, '--version', version]), - ).rejects.toStrictEqual( - new Error( - "We're sorry, your upload request timed out. Please try again or split your file up into smaller chunks.", - ), - ); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should error if no file was provided or able to be discovered', () => { - return expect(run(['--key', key, '--version', version, '--workingDirectory', 'bin'])).rejects.toStrictEqual( - new Error( - "We couldn't find an OpenAPI or Swagger definition.\n\nPlease specify the path to your definition with `rdme openapi ./path/to/api/definition`.", - ), - ); - }); - }); - - describe('GHA onboarding E2E tests', () => { - let yamlOutput; - - beforeEach(() => { - before((fileName, data) => { - yamlOutput = data; - }); - }); - - afterEach(() => { - after(); - }); - - it('should create GHA workflow (create spec)', async () => { - expect.assertions(6); - const yamlFileName = 'openapi-file'; - prompts.inject(['create', true, 'openapi-branch', yamlFileName]); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .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' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, [{ _id: 'spec1', title: 'spec1_title' }]) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run([spec, '--key', key, '--version', version])).resolves.toMatchSnapshot(); - - expect(yamlOutput).toMatchSnapshot(); - expect(fs.writeFileSync).toHaveBeenCalledWith(`.github/workflows/${yamlFileName}.yml`, expect.any(String)); - expect(console.info).toHaveBeenCalledTimes(2); - const output = getCommandOutput(); - expect(output).toMatch("Looks like you're running this command in a GitHub Repository!"); - expect(output).toMatch('successfully uploaded a new OpenAPI file to your ReadMe project'); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should create GHA workflow (--github flag enabled)', async () => { - expect.assertions(6); - const yamlFileName = 'openapi-file-github-flag'; - prompts.inject(['create', 'openapi-branch-github-flag', yamlFileName]); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .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' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, [{ _id: 'spec1', title: 'spec1_title' }]) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run([spec, '--key', key, '--version', version, '--github'])).resolves.toMatchSnapshot(); - - expect(yamlOutput).toMatchSnapshot(); - expect(fs.writeFileSync).toHaveBeenCalledWith(`.github/workflows/${yamlFileName}.yml`, expect.any(String)); - expect(console.info).toHaveBeenCalledTimes(2); - const output = getCommandOutput(); - expect(output).toMatch("Let's get you set up with GitHub Actions!"); - expect(output).toMatch('successfully uploaded a new OpenAPI file to your ReadMe project'); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should create GHA workflow (update spec via prompt)', async () => { - expect.assertions(3); - const yamlFileName = 'openapi-file-update-prompt'; - prompts.inject(['update', 'spec2', true, 'openapi-branch-update-prompt', yamlFileName]); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .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' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, [ - { _id: 'spec1', title: 'spec1_title' }, - { _id: 'spec2', title: 'spec2_title' }, - ]) - .put('/api/v1/api-specification/spec2', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 'spec2' }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run([spec, '--key', key, '--version', version])).resolves.toMatchSnapshot(); - - expect(yamlOutput).toMatchSnapshot(); - expect(fs.writeFileSync).toHaveBeenCalledWith(`.github/workflows/${yamlFileName}.yml`, expect.any(String)); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should create GHA workflow (--create flag enabled)', async () => { - expect.assertions(3); - const yamlFileName = 'openapi-file-create-flag'; - const altVersion = '1.0.1'; - prompts.inject([true, 'openapi-branch-create-flag', yamlFileName]); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .get(`/api/v1/version/${altVersion}`) - .basicAuth({ user: key }) - .reply(200, { version: altVersion }) - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(altVersion) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run([spec, '--key', key, '--version', altVersion, '--create'])).resolves.toMatchSnapshot(); - - expect(yamlOutput).toMatchSnapshot(); - expect(fs.writeFileSync).toHaveBeenCalledWith(`.github/workflows/${yamlFileName}.yml`, expect.any(String)); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should create GHA workflow (--create flag enabled with ignored id opt)', async () => { - expect.assertions(3); - const yamlFileName = 'openapi-file-create-flag-id-opt'; - prompts.inject([version, true, 'openapi-branch-create-flag-id-opt', yamlFileName]); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .get('/api/v1/version') - .basicAuth({ user: key }) - .reply(200, [{ version }, { version: '1.1.0' }]) - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }); - - const postMock = getAPIv1MockWithVersionHeader(version) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run([spec, '--key', key, '--id', 'some-id', '--create'])).resolves.toMatchSnapshot(); - - expect(yamlOutput).toMatchSnapshot(); - expect(fs.writeFileSync).toHaveBeenCalledWith(`.github/workflows/${yamlFileName}.yml`, expect.any(String)); - - postMock.done(); - return mock.done(); - }); - - it('should create GHA workflow (--update flag enabled)', async () => { - expect.assertions(3); - const yamlFileName = 'openapi-file-update-flag'; - prompts.inject([true, 'openapi-branch-update-flag', yamlFileName]); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .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' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, [{ _id: 'spec1', title: 'spec1_title' }]) - .put('/api/v1/api-specification/spec1', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run([spec, '--key', key, '--version', version, '--update'])).resolves.toMatchSnapshot(); - - expect(yamlOutput).toMatchSnapshot(); - expect(fs.writeFileSync).toHaveBeenCalledWith(`.github/workflows/${yamlFileName}.yml`, expect.any(String)); - - mockWithHeader.done(); - return mock.done(); - }); - - it('should create GHA workflow (including workingDirectory)', async () => { - const yamlFileName = 'openapi-file-workingdirectory'; - prompts.inject([true, 'openapi-branch-workingdirectory', yamlFileName]); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }) - .post('/api/v1/api-registry', body => { - return body.match('form-data; name="spec"'); - }) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []); - - const postMock = getAPIv1MockWithVersionHeader(version) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = 'petstore.json'; - - await expect( - run([ - spec, - '--key', - key, - '--version', - version, - '--workingDirectory', - './__tests__/__fixtures__/relative-ref-oas', - ]), - ).resolves.toMatchSnapshot(); - - expect(yamlOutput).toMatchSnapshot(); - expect(fs.writeFileSync).toHaveBeenCalledTimes(2); - expect(fs.writeFileSync).toHaveBeenNthCalledWith(2, `.github/workflows/${yamlFileName}.yml`, expect.any(String)); - - postMock.done(); - return mock.done(); - }); - - it('should reject if user says no to creating GHA workflow', async () => { - prompts.inject(['create', false]); - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .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' } }); - - const mockWithHeader = getAPIv1MockWithVersionHeader(version) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, [{ _id: 'spec1', title: 'spec1_title' }]) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run([spec, '--key', key, '--version', version])).rejects.toStrictEqual( - new Error( - 'GitHub Actions workflow creation cancelled. If you ever change your mind, you can run this command again with the `--github` flag.', - ), - ); - - mockWithHeader.done(); - return mock.done(); - }); - }); - - describe('command execution in GitHub Actions runner', () => { - beforeEach(() => { - beforeGHAEnv(); - }); - - afterEach(afterGHAEnv); - - it('should error out if multiple possible spec matches were found', () => { - return expect(run(['--key', key, '--version', version])).rejects.toStrictEqual( - new Error('Multiple API definitions found in current directory. Please specify file.'), - ); - }); - - it('should send proper headers in GitHub Actions CI for local spec file', async () => { - const registryUUID = getRandomRegistryId(); - - const mock = getAPIv1Mock() - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID }); - - const putMock = getAPIv1Mock({ - 'x-rdme-ci': 'GitHub Actions (test)', - 'x-readme-source': 'cli-gh', - 'x-readme-source-url': - 'https://github.com/octocat/Hello-World/blob/ffac537e6cbbf934b08745a378932722df287a53/__tests__/__fixtures__/ref-oas/petstore.json', - 'x-readme-version': version, - }) - .put(`/api/v1/api-specification/${id}`, { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; - - await expect(run([spec, '--key', key, '--version', version, '--id', id])).resolves.toBe(successfulUpdate(spec)); - - putMock.done(); - return mock.done(); - }); - - it('should send proper headers in GitHub Actions CI for spec hosted at URL', async () => { - const registryUUID = getRandomRegistryId(); - const spec = 'https://example.com/openapi.json'; - - const mock = getAPIv1Mock() - .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) - .reply(201, { registryUUID }); - - const exampleMock = nock('https://example.com').get('/openapi.json').reply(200, petstoreWeird); - - const putMock = getAPIv1Mock({ - 'x-rdme-ci': 'GitHub Actions (test)', - 'x-readme-source': 'cli-gh', - 'x-readme-source-url': spec, - 'x-readme-version': version, - }) - .put(`/api/v1/api-specification/${id}`, { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - await expect(run([spec, '--key', key, '--version', version, '--id', id])).resolves.toBe(successfulUpdate(spec)); - - putMock.done(); - exampleMock.done(); - return mock.done(); - }); - - it('should contain request header with correct URL with working directory', async () => { - const registryUUID = getRandomRegistryId(); - const mock = getAPIv1Mock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }) - .post('/api/v1/api-registry', body => { - return body.match('form-data; name="spec"'); - }) - .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) - .get('/api/v1/api-specification') - .basicAuth({ user: key }) - .reply(200, []); - - const postMock = getAPIv1Mock({ - 'x-rdme-ci': 'GitHub Actions (test)', - 'x-readme-source': 'cli-gh', - 'x-readme-source-url': - 'https://github.com/octocat/Hello-World/blob/ffac537e6cbbf934b08745a378932722df287a53/__tests__/__fixtures__/relative-ref-oas/petstore.json', - 'x-readme-version': version, - }) - .post('/api/v1/api-specification', { registryUUID }) - .basicAuth({ user: key }) - .reply(201, { _id: 1 }, { location: exampleRefLocation }); - - const spec = 'petstore.json'; - - await expect( - run([ - spec, - '--key', - key, - '--version', - version, - '--workingDirectory', - './__tests__/__fixtures__/relative-ref-oas', - ]), - ).resolves.toBe(successfulUpload(spec)); - - after(); - - postMock.done(); - return mock.done(); - }); - }); -}); diff --git a/__tests__/helpers/get-api-mock.ts b/__tests__/helpers/get-api-mock.ts index c8fc83ff9..8ffd07f36 100644 --- a/__tests__/helpers/get-api-mock.ts +++ b/__tests__/helpers/get-api-mock.ts @@ -8,16 +8,10 @@ import { getUserAgent } from '../../src/lib/readmeAPIFetch.js'; * `user-agent` request header so it gets properly picked up by nock. */ export function getAPIv1Mock(reqHeaders = {}) { - return nock(config.host, { + return nock(config.host.v1, { reqheaders: { 'User-Agent': getUserAgent(), ...reqHeaders, }, }); } - -export function getAPIv1MockWithVersionHeader(v: string) { - return getAPIv1Mock({ - 'x-readme-version': v, - }); -} diff --git a/__tests__/lib/__snapshots__/createGHA.test.ts.snap b/__tests__/lib/__snapshots__/createGHA.test.ts.snap index 79d9e2e84..1937da436 100644 --- a/__tests__/lib/__snapshots__/createGHA.test.ts.snap +++ b/__tests__/lib/__snapshots__/createGHA.test.ts.snap @@ -164,88 +164,6 @@ jobs: " `; -exports[`#createGHA > command inputs > 'openapi' '' > should run GHA creation workflow and generate valid workflow file 1`] = ` -" -Your GitHub Actions workflow file has been created! ✨ - -Almost done! Just a couple more steps: -1. Push your newly created file (.github/workflows/rdme-openapi.yml) to GitHub 🚀 -2. Create a GitHub secret called README_API_KEY and populate the value with your ReadMe API key (••••••••••••I_KEY) 🔑 - -🔐 Check out GitHub's docs for more info on creating encrypted secrets (https://docs.github.com/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-a-repository) - -🦉 If you have any more questions, feel free to drop us a line! support@readme.io -" -`; - -exports[`#createGHA > command inputs > 'openapi' '' > should run GHA creation workflow and generate valid workflow file 2`] = ` -"# This GitHub Actions workflow was auto-generated by the \`rdme\` cli on 2022-01-01T00:00:00.000Z -# You can view our full documentation here: https://docs.readme.com/docs/rdme -name: ReadMe GitHub Action 🦉 - -on: - push: - branches: - # This workflow will run every time you push code to the following branch: \`some-branch\` - # Check out GitHub's docs for more info on configuring this: - # https://docs.github.com/actions/using-workflows/events-that-trigger-workflows - - some-branch - -jobs: - rdme-openapi: - runs-on: ubuntu-latest - steps: - - name: Check out repo 📚 - uses: actions/checkout@v4 - - - name: Run \`openapi\` command 🚀 - uses: readmeio/rdme@v7 - with: - rdme: openapi petstore.json --key=\${{ secrets.README_API_KEY }} --id=spec_id -" -`; - -exports[`#createGHA > command inputs > 'openapi' '' > should run GHA creation workflow with \`--github\` flag and messy file name and generate valid workflow file 1`] = ` -" -Your GitHub Actions workflow file has been created! ✨ - -Almost done! Just a couple more steps: -1. Push your newly created file (.github/workflows/rdme-openapi-with-github-flag.yml) to GitHub 🚀 -2. Create a GitHub secret called README_API_KEY and populate the value with your ReadMe API key (••••••••••••I_KEY) 🔑 - -🔐 Check out GitHub's docs for more info on creating encrypted secrets (https://docs.github.com/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-a-repository) - -🦉 If you have any more questions, feel free to drop us a line! support@readme.io -" -`; - -exports[`#createGHA > command inputs > 'openapi' '' > should run GHA creation workflow with \`--github\` flag and messy file name and generate valid workflow file 2`] = ` -"# This GitHub Actions workflow was auto-generated by the \`rdme\` cli on 2022-01-01T00:00:00.000Z -# You can view our full documentation here: https://docs.readme.com/docs/rdme -name: ReadMe GitHub Action 🦉 - -on: - push: - branches: - # This workflow will run every time you push code to the following branch: \`another-branch\` - # Check out GitHub's docs for more info on configuring this: - # https://docs.github.com/actions/using-workflows/events-that-trigger-workflows - - another-branch - -jobs: - rdme-openapi: - runs-on: ubuntu-latest - steps: - - name: Check out repo 📚 - uses: actions/checkout@v4 - - - name: Run \`openapi\` command 🚀 - uses: readmeio/rdme@v7 - with: - rdme: openapi petstore.json --key=\${{ secrets.README_API_KEY }} --id=spec_id -" -`; - exports[`#createGHA > command inputs > 'openapi:validate' '' > should run GHA creation workflow and generate valid workflow file 1`] = ` " Your GitHub Actions workflow file has been created! ✨ diff --git a/__tests__/lib/createGHA.test.ts b/__tests__/lib/createGHA.test.ts index 99f14053a..2f78f0954 100644 --- a/__tests__/lib/createGHA.test.ts +++ b/__tests__/lib/createGHA.test.ts @@ -58,7 +58,6 @@ describe('#createGHA', () => { // `openapi:validate` is the ID we define in src/index.ts for backwards compatibility, // hence we're using this command ID here { cmd: 'openapi:validate', opts: { spec: 'petstore.json' }, label: '' }, - { cmd: 'openapi', opts: { key, spec: 'petstore.json', id: 'spec_id' }, label: '' }, { cmd: 'changelogs', opts: { key, path: './changelogs' }, label: '' }, { cmd: 'changelogs', diff --git a/__tests__/lib/prompts.test.ts b/__tests__/lib/prompts.test.ts deleted file mode 100644 index 94b8287a6..000000000 --- a/__tests__/lib/prompts.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import prompts from 'prompts'; -import { describe, it, expect } from 'vitest'; - -import * as promptHandler from '../../src/lib/prompts.js'; -import promptTerminal from '../../src/lib/promptWrapper.js'; - -const specList = [ - { - _id: 'spec1', - title: 'spec1_title', - }, - { - _id: 'spec2', - title: 'spec2_title', - }, -]; - -const getSpecs = () => { - return { - body: [ - { - _id: 'spec3', - title: 'spec3_title', - }, - ], - } as unknown as Promise; -}; - -describe('prompt test bed', () => { - describe('createOasPrompt()', () => { - it('should return a create option if selected', async () => { - prompts.inject(['create']); - - const answer = await promptTerminal( - promptHandler.createOasPrompt( - [ - { - _id: '1234', - title: 'buster', - }, - ], - {}, - 1, - null, - ), - ); - - expect(answer).toStrictEqual({ option: 'create' }); - }); - - it('should return specId if user chooses to update file', async () => { - prompts.inject(['update', 'spec1']); - - const parsedDocs = { - next: { - page: 2, - url: '', - }, - prev: { - page: 1, - url: '', - }, - }; - - const answer = await promptTerminal(promptHandler.createOasPrompt(specList, parsedDocs, 1, getSpecs)); - - expect(answer).toStrictEqual({ option: 'spec1' }); - }); - }); -}); diff --git a/documentation/commands/openapi.md b/documentation/commands/openapi.md index 56be132d1..df458f17b 100644 --- a/documentation/commands/openapi.md +++ b/documentation/commands/openapi.md @@ -3,115 +3,11 @@ Manage your API definition (e.g., syncing, validation, analysis, conversion, etc.). Supports OpenAPI, Swagger, and Postman collections, in either JSON or YAML formats. -* [`rdme openapi [SPEC]`](#rdme-openapi-spec) * [`rdme openapi convert [SPEC]`](#rdme-openapi-convert-spec) * [`rdme openapi inspect [SPEC]`](#rdme-openapi-inspect-spec) * [`rdme openapi reduce [SPEC]`](#rdme-openapi-reduce-spec) * [`rdme openapi validate [SPEC]`](#rdme-openapi-validate-spec) -## `rdme openapi [SPEC]` - -Upload, or resync, your OpenAPI/Swagger definition to ReadMe. - -``` -USAGE - $ rdme openapi [SPEC] --key [--version ] [--id ] [--title ] - [--workingDirectory ] [--github] [--dryRun] [--useSpecVersion] [--raw] [--create | --update] - -ARGUMENTS - SPEC A file/URL to your API definition - -FLAGS - --create Bypasses the create/update prompt and creates a new API definition in ReadMe. - --dryRun Runs the command without creating/updating any API Definitions in ReadMe. Useful for - debugging. - --github Create a new GitHub Actions workflow for this command. - --id= Unique identifier for your API definition. Use this if you're re-uploading an existing API - definition. - --key= (required) ReadMe project API key - --raw Return the command results as a JSON object instead of a pretty output. - --title= An override value for the `info.title` field in the API definition - --update Bypasses the create/update prompt and automatically updates an existing API definition in - ReadMe. - --useSpecVersion Uses the version listed in the `info.version` field in the API definition for the project - version parameter. - --version= ReadMe project version - --workingDirectory= Working directory (for usage with relative external references) - -DESCRIPTION - Upload, or resync, your OpenAPI/Swagger definition to ReadMe. - - Locates your API definition (if you don't supply one), validates it, and then syncs it to your API reference on - ReadMe. - -EXAMPLES - This will upload the API definition at the given URL or path to your project and return an ID and URL for you to - later update your file, and view it in the client: - - $ rdme openapi [url-or-local-path-to-file] - - You can omit the file name and `rdme` will scan your working directory (and any subdirectories) for OpenAPI/Swagger - files. This approach will provide you with CLI prompts, so we do not recommend this technique in CI environments. - - $ rdme openapi - - If you want to bypass the prompt to create or update an API definition, you can pass the `--create` flag: - - $ rdme openapi [url-or-local-path-to-file] --version={project-version} --create - - This will edit (re-sync) an existing API definition (identified by `--id`) within your ReadMe project. **This is the - recommended approach for usage in CI environments.** - - $ rdme openapi [url-or-local-path-to-file] --id={existing-api-definition-id} - - Alternatively, you can include a version flag, which specifies the target version for your file's destination. This - approach will provide you with CLI prompts, so we do not recommend this technique in CI environments. - - $ rdme openapi [url-or-local-path-to-file] --id={existing-api-definition-id} - - If you wish to programmatically access any of this script's results (such as the API definition ID or the link to - the corresponding docs in your dashboard), supply the `--raw` flag and the command will return a JSON output: - - $ rdme openapi openapi.json --id={existing-api-definition-id} --raw - - You can also pass in a file in a subdirectory (we recommend running the CLI from the root of your repository if - possible): - - $ rdme openapi example-directory/petstore.json - - By default, `rdme` bundles all references with paths based on the directory that it is being run in. You can - override the working directory using the `--workingDirectory` option, which can be helpful for bundling certain - external references: - - $ rdme openapi petstore.json --workingDirectory=[path to directory] - - If you wish to use the version specified in the `info.version` field of your OpenAPI definition, you can pass the - `--useSpecVersion` option. So if the the `info.version` field was `1.2.3`, this is equivalent to passing - `--version=1.2.3`. - - $ rdme openapi [url-or-local-path-to-file] --useSpecVersion - - If there's only one API definition for the given project version to update, you can use the `--update` flag and it - will select it without any prompts: - - $ rdme openapi [url-or-local-path-to-file] --version={project-version} --update - -FLAG DESCRIPTIONS - --key= ReadMe project API key - - An API key for your ReadMe project. Note that API authentication is required despite being omitted from the example - usage. See our docs for more information: https://github.com/readmeio/rdme/tree/v10#authentication - - --update Bypasses the create/update prompt and automatically updates an existing API definition in ReadMe. - - Note that this flag only works if there's only one API definition associated with the current version. - - --version= ReadMe project version - - If running command in a CI environment and this option is not passed, the main project version will be used. See our - docs for more information: https://docs.readme.com/main/docs/versions -``` - ## `rdme openapi convert [SPEC]` Converts an API definition to OpenAPI and bundles any external references. diff --git a/package-lock.json b/package-lock.json index 6bb8b5d4f..933403229 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,14 +25,12 @@ "oas": "^25.0.0", "oas-normalize": "^11.1.2", "ora": "^8.1.1", - "parse-link-header": "^2.0.0", "pluralize": "^8.0.0", "prompts": "^2.4.2", "semver": "^7.5.3", "simple-git": "^3.19.1", "string-argv": "^0.3.2", "table": "^6.8.1", - "tmp-promise": "^3.0.2", "toposort": "^2.0.2", "undici": "^5.28.4", "validator": "^13.7.0" @@ -56,7 +54,6 @@ "@types/debug": "^4.1.7", "@types/js-yaml": "^4.0.5", "@types/mime-types": "^2.1.1", - "@types/parse-link-header": "^2.0.0", "@types/pluralize": "^0.0.33", "@types/prompts": "^2.4.2", "@types/semver": "^7.3.12", @@ -4418,13 +4415,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/parse-link-header": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/parse-link-header/-/parse-link-header-2.0.3.tgz", - "integrity": "sha512-ffLAxD6Xqcf2gSbtEJehj8yJ5R/2OZqD4liodQvQQ+hhO4kg1mk9ToEZQPMtNTm/zIQj2GNleQbsjPp9+UQm4Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/pluralize": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.33.tgz", @@ -14307,15 +14297,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/parse-link-header": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/parse-link-header/-/parse-link-header-2.0.0.tgz", - "integrity": "sha512-xjU87V0VyHZybn2RrCX5TIFGxTVZE6zqqZWMPlIKiSKuWh/X5WZdt+w1Ki1nXB+8L/KtL+nZ4iq+sfI6MrhhMw==", - "license": "MIT", - "dependencies": { - "xtend": "~4.0.1" - } - }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -16842,24 +16823,6 @@ "node": ">=0.6.0" } }, - "node_modules/tmp-promise": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", - "license": "MIT", - "dependencies": { - "tmp": "^0.2.0" - } - }, - "node_modules/tmp-promise/node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -18899,6 +18862,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4" diff --git a/package.json b/package.json index d28daaf3a..137168d1a 100644 --- a/package.json +++ b/package.json @@ -57,14 +57,12 @@ "oas": "^25.0.0", "oas-normalize": "^11.1.2", "ora": "^8.1.1", - "parse-link-header": "^2.0.0", "pluralize": "^8.0.0", "prompts": "^2.4.2", "semver": "^7.5.3", "simple-git": "^3.19.1", "string-argv": "^0.3.2", "table": "^6.8.1", - "tmp-promise": "^3.0.2", "toposort": "^2.0.2", "undici": "^5.28.4", "validator": "^13.7.0" @@ -85,7 +83,6 @@ "@types/debug": "^4.1.7", "@types/js-yaml": "^4.0.5", "@types/mime-types": "^2.1.1", - "@types/parse-link-header": "^2.0.0", "@types/pluralize": "^0.0.33", "@types/prompts": "^2.4.2", "@types/semver": "^7.3.12", diff --git a/src/commands/openapi/index.ts b/src/commands/openapi/index.ts deleted file mode 100644 index d216b58ae..000000000 --- a/src/commands/openapi/index.ts +++ /dev/null @@ -1,353 +0,0 @@ -import type { OpenAPIPromptOptions } from '../../lib/prompts.js'; - -import { Args, Flags } from '@oclif/core'; -import chalk from 'chalk'; -import ora from 'ora'; -import parse from 'parse-link-header'; - -import BaseCommand from '../../lib/baseCommand.js'; -import { githubFlag, keyFlag, titleFlag, versionFlag, workingDirectoryFlag } from '../../lib/flags.js'; -import { info, oraOptions, warn } from '../../lib/logger.js'; -import prepareOas from '../../lib/prepareOas.js'; -import * as promptHandler from '../../lib/prompts.js'; -import promptTerminal from '../../lib/promptWrapper.js'; -import { cleanAPIv1Headers, handleAPIv1Res, readmeAPIv1Fetch } from '../../lib/readmeAPIFetch.js'; -import streamSpecToRegistry from '../../lib/streamSpecToRegistry.js'; -import { getProjectVersion } from '../../lib/versionSelect.js'; - -export default class OpenAPICommand extends BaseCommand { - static summary = 'Upload, or resync, your OpenAPI/Swagger definition to ReadMe.'; - - static description = - "Locates your API definition (if you don't supply one), validates it, and then syncs it to your API reference on ReadMe."; - - // needed for unit tests, even though we also specify this in src/index.ts - static id = 'openapi' as const; - - static state = 'deprecated'; - - static deprecationOptions = { - message: `\`rdme ${this.id}\` is deprecated and v10 will have a replacement command that supports ReadMe Refactored. For more information, please visit our migration guide: https://github.com/readmeio/rdme/tree/v9/documentation/migration-guide.md`, - }; - - static args = { - spec: Args.string({ description: 'A file/URL to your API definition' }), - }; - - static flags = { - key: keyFlag, - version: versionFlag, - id: Flags.string({ - description: - "Unique identifier for your API definition. Use this if you're re-uploading an existing API definition.", - }), - title: titleFlag, - workingDirectory: workingDirectoryFlag, - github: githubFlag, - dryRun: Flags.boolean({ - description: 'Runs the command without creating/updating any API Definitions in ReadMe. Useful for debugging.', - }), - useSpecVersion: Flags.boolean({ - description: - 'Uses the version listed in the `info.version` field in the API definition for the project version parameter.', - }), - raw: Flags.boolean({ description: 'Return the command results as a JSON object instead of a pretty output.' }), - create: Flags.boolean({ - description: 'Bypasses the create/update prompt and creates a new API definition in ReadMe.', - exclusive: ['update'], // this prevents `--create` and `--update` from being used simultaneously - }), - update: Flags.boolean({ - description: - "Note that this flag only works if there's only one API definition associated with the current version.", - summary: 'Bypasses the create/update prompt and automatically updates an existing API definition in ReadMe.', - }), - }; - - static examples = [ - { - description: - 'This will upload the API definition at the given URL or path to your project and return an ID and URL for you to later update your file, and view it in the client:', - command: '<%= config.bin %> <%= command.id %> [url-or-local-path-to-file]', - }, - { - description: - 'You can omit the file name and `rdme` will scan your working directory (and any subdirectories) for OpenAPI/Swagger files. This approach will provide you with CLI prompts, so we do not recommend this technique in CI environments.', - command: '<%= config.bin %> <%= command.id %>', - }, - { - description: - 'If you want to bypass the prompt to create or update an API definition, you can pass the `--create` flag:', - command: '<%= config.bin %> <%= command.id %> [url-or-local-path-to-file] --version={project-version} --create', - }, - { - description: - 'This will edit (re-sync) an existing API definition (identified by `--id`) within your ReadMe project. **This is the recommended approach for usage in CI environments.**', - command: '<%= config.bin %> <%= command.id %> [url-or-local-path-to-file] --id={existing-api-definition-id}', - }, - { - description: - "Alternatively, you can include a version flag, which specifies the target version for your file's destination. This approach will provide you with CLI prompts, so we do not recommend this technique in CI environments.", - command: '<%= config.bin %> <%= command.id %> [url-or-local-path-to-file] --id={existing-api-definition-id}', - }, - { - description: - "If you wish to programmatically access any of this script's results (such as the API definition ID or the link to the corresponding docs in your dashboard), supply the `--raw` flag and the command will return a JSON output:", - command: '<%= config.bin %> <%= command.id %> openapi.json --id={existing-api-definition-id} --raw', - }, - { - description: - 'You can also pass in a file in a subdirectory (we recommend running the CLI from the root of your repository if possible):', - command: '<%= config.bin %> <%= command.id %> example-directory/petstore.json', - }, - { - description: - 'By default, `<%= config.bin %>` bundles all references with paths based on the directory that it is being run in. You can override the working directory using the `--workingDirectory` option, which can be helpful for bundling certain external references:', - command: '<%= config.bin %> <%= command.id %> petstore.json --workingDirectory=[path to directory]', - }, - { - description: - 'If you wish to use the version specified in the `info.version` field of your OpenAPI definition, you can pass the `--useSpecVersion` option. So if the the `info.version` field was `1.2.3`, this is equivalent to passing `--version=1.2.3`.', - command: '<%= config.bin %> <%= command.id %> [url-or-local-path-to-file] --useSpecVersion', - }, - { - description: - "If there's only one API definition for the given project version to update, you can use the `--update` flag and it will select it without any prompts:", - command: '<%= config.bin %> <%= command.id %> [url-or-local-path-to-file] --version={project-version} --update', - }, - ]; - - async run() { - const { spec } = this.args; - const { dryRun, key, id, create, raw, title, useSpecVersion, version, workingDirectory, update } = this.flags; - - let selectedVersion = version; - let isUpdate: boolean; - const spinner = ora({ ...oraOptions() }); - /** - * The `version` and `update` parameters are not typically ones we'd want to include - * in GitHub Actions workflow files, so we're going to collect them in this object. - */ - const ignoredGHAParameters: Partial = { version: undefined, update: false }; - - if (dryRun) { - warn('🎭 dry run option detected! No API definitions will be created or updated in ReadMe.'); - } - - if (workingDirectory) { - const previousWorkingDirectory = process.cwd(); - process.chdir(workingDirectory); - this.debug(`switching working directory from ${previousWorkingDirectory} to ${process.cwd()}`); - } - - if (version && id) { - warn("We'll be using the version associated with the `--id` option, so the `--version` option will be ignored."); - } - - if (create && id) { - warn("We'll be using the `--create` option, so the `--id` parameter will be ignored."); - } - - if (update && id) { - warn( - "We'll be updating the API definition associated with the `--id` parameter, so the `--update` parameter will be ignored.", - ); - } - - const { preparedSpec, specFileType, specPath, specType, specVersion } = await prepareOas(spec, 'openapi', { - title, - }); - - if (useSpecVersion) { - info(`Using the version specified in your API definition for your ReadMe project version (${specVersion})`); - selectedVersion = specVersion; - } - - if (create || !id) { - selectedVersion = await getProjectVersion(selectedVersion, key); - } - - this.debug(`selectedVersion: ${selectedVersion}`); - - const success = async (data: Response) => { - const message = !isUpdate - ? `You've successfully uploaded a new ${specType} file to your ReadMe project!` - : `You've successfully updated an existing ${specType} file on your ReadMe project!`; - - const body = await handleAPIv1Res(data, false); - - const output = { - commandType: isUpdate ? 'update' : 'create', - docs: data.headers.get('location'), - // eslint-disable-next-line no-underscore-dangle - id: body._id, - specPath, - specType, - version: selectedVersion, - }; - - const prettyOutput = [ - message, - '', - `\t${chalk.green(output.docs)}`, - '', - `To update your ${specType} definition, run the following:`, - '', - `\t${chalk.green(`rdme openapi ${specPath} --key= --id=${output.id}`)}`, - ].join('\n'); - - return this.runCreateGHAHook({ - parsedOpts: { - ...this.flags, - spec: specPath, - // eslint-disable-next-line no-underscore-dangle - id: body._id, - version: selectedVersion, - ...ignoredGHAParameters, - }, - result: raw ? JSON.stringify(output, null, 2) : prettyOutput, - }); - }; - - const error = (res: Response) => { - return handleAPIv1Res(res).catch(err => { - // If we receive an APIv1Error, no changes needed! Throw it as is. - if (err.name === 'APIv1Error') { - throw err; - } - - // If we receive certain text responses, it's likely a 5xx error from our server. - if ( - typeof err === 'string' && - (err.includes('Application Error') || // Heroku error - err.includes('520: Web server is returning an unknown error')) // Cloudflare error - ) { - throw new Error( - "We're sorry, your upload request timed out. Please try again or split your file up into smaller chunks.", - ); - } - - // As a fallback, we throw a more generic error. - throw new Error( - `Yikes, something went wrong! Please try uploading your spec again and if the problem persists, get in touch with our support team at ${chalk.underline( - 'support@readme.io', - )}.`, - ); - }); - }; - - const registryUUID = await streamSpecToRegistry(preparedSpec); - - const options: RequestInit = { - headers: cleanAPIv1Headers( - key, - selectedVersion, - new Headers({ Accept: 'application/json', 'Content-Type': 'application/json' }), - ), - body: JSON.stringify({ registryUUID }), - }; - - function createSpec() { - if (dryRun) { - return `🎭 dry run! The API Definition located at ${specPath} will be created for this project version: ${selectedVersion}`; - } - - options.method = 'post'; - spinner.start('Creating your API docs in ReadMe...'); - return readmeAPIv1Fetch('/api/v1/api-specification', options, { - filePath: specPath, - fileType: specFileType, - }).then(res => { - if (res.ok) { - spinner.succeed(`${spinner.text} done! 🦉`); - return success(res); - } - spinner.fail(); - return error(res); - }); - } - - function updateSpec(specId: string) { - if (dryRun) { - return `🎭 dry run! The API Definition located at ${specPath} will update this API Definition ID: ${specId}`; - } - - isUpdate = true; - options.method = 'put'; - spinner.start('Updating your API docs in ReadMe...'); - return readmeAPIv1Fetch(`/api/v1/api-specification/${specId}`, options, { - filePath: specPath, - fileType: specFileType, - }).then(res => { - if (res.ok) { - spinner.succeed(`${spinner.text} done! 🦉`); - return success(res); - } - spinner.fail(); - return error(res); - }); - } - - /* - Create a new OAS file in Readme: - - Enter flow if user does not pass an id as cli arg - - Check to see if any existing files exist with a specific version - - If none exist, default to creating a new instance of a spec - - If found, prompt user to either create a new spec or update an existing one - */ - - function getSpecs(url: string) { - if (url) { - return readmeAPIv1Fetch(url, { - method: 'get', - headers: cleanAPIv1Headers(key, selectedVersion), - }); - } - - throw new Error( - 'There was an error retrieving your list of API definitions. Please get in touch with us at support@readme.io', - ); - } - - if (create) { - ignoredGHAParameters.id = undefined; - delete ignoredGHAParameters.version; - return createSpec(); - } - - if (!id) { - this.debug('no id parameter, retrieving list of API specs'); - const apiSettings = await getSpecs('/api/v1/api-specification'); - - const totalPages = Math.ceil(parseInt(apiSettings.headers.get('x-total-count') || '0', 10) / 10); - const parsedDocs = parse(apiSettings.headers.get('link')); - this.debug(`total pages: ${totalPages}`); - this.debug(`pagination result: ${JSON.stringify(parsedDocs)}`); - - const apiSettingsBody = await handleAPIv1Res(apiSettings); - if (!apiSettingsBody.length) return createSpec(); - - if (update) { - if (apiSettingsBody.length > 1) { - throw new Error( - `The \`--update\` option cannot be used when there's more than one API definition available (found ${apiSettingsBody.length}).`, - ); - } - const { _id: specId } = apiSettingsBody[0]; - return updateSpec(specId); - } - - const { option }: { option: OpenAPIPromptOptions } = await promptTerminal( - promptHandler.createOasPrompt(apiSettingsBody, parsedDocs, totalPages, getSpecs), - ); - this.debug(`selection result: ${option}`); - - return option === 'create' ? createSpec() : updateSpec(option); - } - - /* - Update an existing OAS file in Readme: - - Enter flow if user passes an id as cli arg - */ - return updateSpec(id); - } -} diff --git a/src/index.ts b/src/index.ts index 7cc63a2e9..ebba17c98 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,6 @@ import ChangelogsCommand from './commands/changelogs.js'; import LoginCommand from './commands/login.js'; import LogoutCommand from './commands/logout.js'; import OpenAPIConvertCommand from './commands/openapi/convert.js'; -import OpenAPICommand from './commands/openapi/index.js'; import OpenAPIInspectCommand from './commands/openapi/inspect.js'; import OpenAPIReduceCommand from './commands/openapi/reduce.js'; import OpenAPIValidateCommand from './commands/openapi/validate.js'; @@ -27,7 +26,6 @@ export const COMMANDS = { login: LoginCommand, logout: LogoutCommand, - openapi: OpenAPICommand, 'openapi:convert': OpenAPIConvertCommand, 'openapi:inspect': OpenAPIInspectCommand, 'openapi:reduce': OpenAPIReduceCommand, diff --git a/src/lib/apiError.ts b/src/lib/apiError.ts index b7c7970b5..0aab9ab87 100644 --- a/src/lib/apiError.ts +++ b/src/lib/apiError.ts @@ -1,7 +1,7 @@ /** * APIv1ErrorResponse is the shape of the error response we get from ReadMe API v1. */ -export interface APIv1ErrorResponse { +interface APIv1ErrorResponse { docs?: string; error: string; help?: string; diff --git a/src/lib/config.ts b/src/lib/config.ts index 98054f4ce..d07afd01a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,5 +1,7 @@ const config = { - host: 'https://dash.readme.com', + host: { + v1: 'https://dash.readme.com', + }, hub: 'https://{project}.readme.io', // this is only used for the `rdme open` command } as const; diff --git a/src/lib/flags.ts b/src/lib/flags.ts index fe1a66443..ae28876fa 100644 --- a/src/lib/flags.ts +++ b/src/lib/flags.ts @@ -22,15 +22,6 @@ export const titleFlag = Flags.string({ description: 'An override value for the `info.title` field in the API definition', }); -/** - * Used in any command where `version` is a flag. - */ -export const versionFlag = Flags.string({ - summary: 'ReadMe project version', - description: - 'If running command in a CI environment and this option is not passed, the main project version will be used. See our docs for more information: https://docs.readme.com/main/docs/versions', -}); - /** * Used in the `openapi` family of commands where `workingDirectory` is an option. */ diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts deleted file mode 100644 index 0ba6c0b23..000000000 --- a/src/lib/prompts.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { Choice, PromptObject } from 'prompts'; - -import parse from 'parse-link-header'; - -import { debug } from './logger.js'; -import promptTerminal from './promptWrapper.js'; -import { handleAPIv1Res } from './readmeAPIFetch.js'; - -interface Spec { - _id: string; - title: string; -} - -export type OpenAPIPromptOptions = 'create' | 'update'; - -type SpecList = Spec[]; - -interface ParsedDocs { - next?: { - page: number; - url: string; - }; - prev?: { - page: number; - url: string; - }; -} - -function specOptions( - specList: SpecList, - parsedDocs: ParsedDocs | null, - currPage: number, - totalPages: number, -): Choice[] { - const specs = specList.map(s => { - return { - description: `API Definition ID: ${s._id}`, // eslint-disable-line no-underscore-dangle - title: s.title, - value: s._id, // eslint-disable-line no-underscore-dangle - }; - }); - if (parsedDocs?.prev?.page) { - specs.push({ - description: 'Go to the previous page', - title: `< Prev (page ${currPage - 1} of ${totalPages})`, - value: 'prev', - }); - } - if (parsedDocs?.next?.page) { - specs.push({ - description: 'Go to the next page', - title: `Next (page ${currPage + 1} of ${totalPages}) >`, - value: 'next', - }); - } - return specs; -} - -const updateOasPrompt = ( - specList: SpecList, - parsedDocs: ParsedDocs | null, - currPage: number, - totalPages: number, - getSpecs: (url: string) => Promise, -): PromptObject<'specId'>[] => [ - { - type: 'select', - name: 'specId', - message: 'Select your desired file to update', - choices: specOptions(specList, parsedDocs, currPage, totalPages), - async format(spec: string) { - if (spec === 'prev') { - try { - const newSpecs = await getSpecs(`${parsedDocs?.prev?.url || ''}`); - const newParsedDocs = parse(newSpecs.headers.get('link')); - const newSpecList = await handleAPIv1Res(newSpecs); - const { specId }: { specId: string } = await promptTerminal( - updateOasPrompt(newSpecList, newParsedDocs, currPage - 1, totalPages, getSpecs), - ); - return specId; - } catch (e) { - debug(`error retrieving previous specs: ${e.message}`); - return null; - } - } else if (spec === 'next') { - try { - const newSpecs = await getSpecs(`${parsedDocs?.next?.url || ''}`); - const newParsedDocs = parse(newSpecs.headers.get('link')); - const newSpecList = await handleAPIv1Res(newSpecs); - const { specId }: { specId: string } = await promptTerminal( - updateOasPrompt(newSpecList, newParsedDocs, currPage + 1, totalPages, getSpecs), - ); - return specId; - } catch (e) { - debug(`error retrieving next specs: ${e.message}`); - return null; - } - } - - return spec; - }, - }, -]; - -export function createOasPrompt( - specList: SpecList, - parsedDocs: ParsedDocs | null, - totalPages: number, - getSpecs: (url: string) => Promise, -): PromptObject<'option'>[] { - return [ - { - type: 'select', - name: 'option', - message: 'Would you like to update an existing OAS file or create a new one?', - choices: [ - { title: 'Update existing', value: 'update' }, - { title: 'Create a new spec', value: 'create' }, - ], - async format(picked: OpenAPIPromptOptions) { - if (picked === 'update') { - const { specId }: { specId: string } = await promptTerminal( - updateOasPrompt(specList, parsedDocs, 1, totalPages, getSpecs), - ); - return specId; - } - - return picked; - }, - }, - ]; -} diff --git a/src/lib/readmeAPIFetch.ts b/src/lib/readmeAPIFetch.ts index 56aab4b4e..29c4bfbc5 100644 --- a/src/lib/readmeAPIFetch.ts +++ b/src/lib/readmeAPIFetch.ts @@ -178,7 +178,7 @@ export async function readmeAPIv1Fetch( headers.set('x-readme-source-url', fileOpts.filePath); } - const fullUrl = `${config.host}${pathname}`; + const fullUrl = `${config.host.v1}${pathname}`; const proxy = getProxy(); debug( diff --git a/src/lib/streamSpecToRegistry.ts b/src/lib/streamSpecToRegistry.ts deleted file mode 100644 index e494d4c9f..000000000 --- a/src/lib/streamSpecToRegistry.ts +++ /dev/null @@ -1,57 +0,0 @@ -import fs from 'node:fs'; - -import ora from 'ora'; -import { file as tmpFile } from 'tmp-promise'; - -import { debug, oraOptions } from './logger.js'; -import { handleAPIv1Res, readmeAPIv1Fetch } from './readmeAPIFetch.js'; - -/** - * Uploads a spec to the API registry for usage in ReadMe - * - * @returns a UUID in the API registry - */ -export default async function streamSpecToRegistry( - /** - * path to a bundled/validated spec file - */ - spec: string, -): Promise { - const spinner = ora({ text: 'Staging your API definition for upload...', ...oraOptions() }).start(); - // Create a temporary file to write the bundled spec to, - // which we will then stream into the form data body - const { path } = await tmpFile({ prefix: 'rdme-openapi-', postfix: '.json' }); - debug(`creating temporary file at ${path}`); - await fs.writeFileSync(path, spec); - const stream = fs.createReadStream(path); - - debug('file and stream created, streaming into form data payload'); - const formData = new FormData(); - formData.append('spec', { - type: 'application/json', - name: 'openapi.json', - [Symbol.toStringTag]: 'File', - stream() { - return stream; - }, - }); - - const options = { - body: formData, - headers: { - Accept: 'application/json', - }, - method: 'POST', - }; - - return readmeAPIv1Fetch('/api/v1/api-registry', options) - .then(handleAPIv1Res) - .then(body => { - spinner.stop(); - return body.registryUUID; - }) - .catch(e => { - spinner.fail(); - throw e; - }); -} diff --git a/src/lib/versionSelect.ts b/src/lib/versionSelect.ts deleted file mode 100644 index 58646e12c..000000000 --- a/src/lib/versionSelect.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { APIv1ErrorResponse } from './apiError.js'; - -import { APIv1Error } from './apiError.js'; -import isCI from './isCI.js'; -import { warn } from './logger.js'; -import promptTerminal from './promptWrapper.js'; -import { cleanAPIv1Headers, handleAPIv1Res, readmeAPIv1Fetch } from './readmeAPIFetch.js'; - -interface Version { - codename?: string; - createdAt?: string; - from?: string; - is_beta?: boolean; - is_deprecated?: boolean; - is_hidden?: boolean; - is_stable: boolean; - version: string; -} - -/** - * Validates and returns a project version. - * - * @param versionFlag version input parameter - * @param key project API key - * @returns a cleaned up project version - */ -export async function getProjectVersion( - versionFlag: string | undefined, - key: string, - returnStable = false, -): Promise { - try { - if (versionFlag) { - return await readmeAPIv1Fetch(`/api/v1/version/${versionFlag}`, { - method: 'get', - headers: cleanAPIv1Headers(key), - }) - .then(handleAPIv1Res) - .then((res: Version) => res.version); - } - - if (isCI()) { - warn('No `--version` parameter detected in current CI environment. Defaulting to main version.'); - return undefined; - } - - const versionList: Version[] = await readmeAPIv1Fetch('/api/v1/version', { - method: 'get', - headers: cleanAPIv1Headers(key), - }).then(handleAPIv1Res); - - if (versionList.length === 1) { - return versionList[0].version; - } - - if (returnStable) { - const stableVersion = versionList.find(v => v.is_stable === true); - if (!stableVersion) { - throw new Error('Unexpected version response from the ReadMe API. Get in touch with us at support@readme.io!'); - } - return stableVersion.version; - } - - const { versionSelection } = await promptTerminal({ - type: 'select', - name: 'versionSelection', - message: 'Select your desired version', - choices: versionList.map(v => { - return { - title: v.version, - value: v.version, - }; - }), - }); - - return versionSelection; - } catch (err) { - return Promise.reject(new APIv1Error(err as APIv1ErrorResponse)); - } -}