diff --git a/docs/usage-data.md b/docs/usage-data.md index 9cd94e3f..2c161efe 100644 --- a/docs/usage-data.md +++ b/docs/usage-data.md @@ -16,6 +16,8 @@ When a command is run, the following data is collected: - values from `REDOCLY_ENVIRONMENT` - CLI version - Node.js and NPM versions +- whether the `redocly.yaml` configuration file exists +- API specification version Values such as file names, organization IDs, and URLs are removed, replaced by just "URL" or "file", etc. diff --git a/packages/cli/src/__tests__/commands/build-docs.test.ts b/packages/cli/src/__tests__/commands/build-docs.test.ts index 39583b98..37438b6e 100644 --- a/packages/cli/src/__tests__/commands/build-docs.test.ts +++ b/packages/cli/src/__tests__/commands/build-docs.test.ts @@ -39,8 +39,8 @@ describe('build-docs', () => { it('should work correctly when calling handlerBuildCommand', async () => { const processExitMock = jest.spyOn(process, 'exit').mockImplementation(); - await handlerBuildCommand( - { + await handlerBuildCommand({ + argv: { o: '', title: 'test', disableGoogleFont: false, @@ -49,8 +49,9 @@ describe('build-docs', () => { theme: { openapi: {} }, api: '../some-path/openapi.yaml', } as BuildDocsArgv, - {} as any - ); + config: {} as any, + version: 'cli-version', + }); expect(loadAndBundleSpec).toBeCalledTimes(1); expect(getFallbackApisOrExit).toBeCalledTimes(1); expect(processExitMock).toBeCalledTimes(0); diff --git a/packages/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts index 3ee2caf0..8980be4c 100644 --- a/packages/cli/src/__tests__/commands/join.test.ts +++ b/packages/cli/src/__tests__/commands/join.test.ts @@ -14,21 +14,21 @@ describe('handleJoin', () => { colloreteYellowMock.mockImplementation((string: string) => string); it('should call exitWithError because only one entrypoint', async () => { - await handleJoin({ apis: ['first.yaml'] }, {} as any, 'cli-version'); + await handleJoin({ argv: { apis: ['first.yaml'] }, config: {} as any, version: 'cli-version' }); expect(exitWithError).toHaveBeenCalledWith(`At least 2 apis should be provided.`); }); it('should call exitWithError because passed all 3 options for tags', async () => { - await handleJoin( - { + await handleJoin({ + argv: { apis: ['first.yaml', 'second.yaml'], 'prefix-tags-with-info-prop': 'something', 'without-x-tag-groups': true, 'prefix-tags-with-filename': true, }, - {} as any, - 'cli-version' - ); + config: {} as any, + version: 'cli-version', + }); expect(exitWithError).toHaveBeenCalledWith( `You use prefix-tags-with-filename, prefix-tags-with-info-prop, without-x-tag-groups together.\nPlease choose only one!` @@ -36,15 +36,15 @@ describe('handleJoin', () => { }); it('should call exitWithError because passed all 2 options for tags', async () => { - await handleJoin( - { + await handleJoin({ + argv: { apis: ['first.yaml', 'second.yaml'], 'without-x-tag-groups': true, 'prefix-tags-with-filename': true, }, - {} as any, - 'cli-version' - ); + config: {} as any, + version: 'cli-version', + }); expect(exitWithError).toHaveBeenCalledWith( `You use prefix-tags-with-filename, without-x-tag-groups together.\nPlease choose only one!` @@ -52,13 +52,13 @@ describe('handleJoin', () => { }); it('should call exitWithError because Only OpenAPI 3.0 and OpenAPI 3.1 are supported', async () => { - await handleJoin( - { + await handleJoin({ + argv: { apis: ['first.yaml', 'second.yaml'], }, - ConfigFixture as any, - 'cli-version' - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(exitWithError).toHaveBeenCalledWith( 'Only OpenAPI 3.0 and OpenAPI 3.1 are supported: undefined.' ); @@ -68,13 +68,13 @@ describe('handleJoin', () => { (detectSpec as jest.Mock) .mockImplementationOnce(() => 'oas3_0') .mockImplementationOnce(() => 'oas3_1'); - await handleJoin( - { + await handleJoin({ + argv: { apis: ['first.yaml', 'second.yaml'], }, - ConfigFixture as any, - 'cli-version' - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(exitWithError).toHaveBeenCalledWith( 'All APIs must use the same OpenAPI version: undefined.' @@ -83,13 +83,13 @@ describe('handleJoin', () => { it('should call writeToFileByExtension function', async () => { (detectSpec as jest.Mock).mockReturnValue('oas3_0'); - await handleJoin( - { + await handleJoin({ + argv: { apis: ['first.yaml', 'second.yaml'], }, - ConfigFixture as any, - 'cli-version' - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(writeToFileByExtension).toHaveBeenCalledWith( expect.any(Object), @@ -100,13 +100,13 @@ describe('handleJoin', () => { it('should call writeToFileByExtension function for OpenAPI 3.1', async () => { (detectSpec as jest.Mock).mockReturnValue('oas3_1'); - await handleJoin( - { + await handleJoin({ + argv: { apis: ['first.yaml', 'second.yaml'], }, - ConfigFixture as any, - 'cli-version' - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(writeToFileByExtension).toHaveBeenCalledWith( expect.any(Object), @@ -117,14 +117,14 @@ describe('handleJoin', () => { it('should call writeToFileByExtension function with custom output file', async () => { (detectSpec as jest.Mock).mockReturnValue('oas3_0'); - await handleJoin( - { + await handleJoin({ + argv: { apis: ['first.yaml', 'second.yaml'], output: 'output.yml', }, - ConfigFixture as any, - 'cli-version' - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(writeToFileByExtension).toHaveBeenCalledWith( expect.any(Object), @@ -135,13 +135,13 @@ describe('handleJoin', () => { it('should call writeToFileByExtension function with json file extension', async () => { (detectSpec as jest.Mock).mockReturnValue('oas3_0'); - await handleJoin( - { + await handleJoin({ + argv: { apis: ['first.json', 'second.yaml'], }, - ConfigFixture as any, - 'cli-version' - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(writeToFileByExtension).toHaveBeenCalledWith( expect.any(Object), @@ -152,13 +152,13 @@ describe('handleJoin', () => { it('should call skipDecorators and skipPreprocessors', async () => { (detectSpec as jest.Mock).mockReturnValue('oas3_0'); - await handleJoin( - { + await handleJoin({ + argv: { apis: ['first.yaml', 'second.yaml'], }, - ConfigFixture as any, - 'cli-version' - ); + config: ConfigFixture as any, + version: 'cli-version', + }); const config = loadConfig(); expect(config.styleguide.skipDecorators).toHaveBeenCalled(); @@ -168,15 +168,15 @@ describe('handleJoin', () => { it('should handle join with prefix-components-with-info-prop and null values', async () => { (detectSpec as jest.Mock).mockReturnValue('oas3_0'); - await handleJoin( - { + await handleJoin({ + argv: { apis: ['first.yaml', 'second.yaml', 'third.yaml'], 'prefix-components-with-info-prop': 'title', output: 'join-result.yaml', }, - ConfigFixture as any, - 'cli-version' - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(writeToFileByExtension).toHaveBeenCalledWith( { diff --git a/packages/cli/src/__tests__/commands/push-region.test.ts b/packages/cli/src/__tests__/commands/push-region.test.ts index 8dd66631..a0e4bb18 100644 --- a/packages/cli/src/__tests__/commands/push-region.test.ts +++ b/packages/cli/src/__tests__/commands/push-region.test.ts @@ -28,30 +28,32 @@ describe('push-with-region', () => { it('should call login with default domain when region is US', async () => { redoclyClient.domain = 'redoc.ly'; - await handlePush( - { + await handlePush({ + argv: { upsert: true, api: 'spec.json', destination: '@org/my-api@1.0.0', branchName: 'test', }, - ConfigFixture as any - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(mockPromptClientToken).toBeCalledTimes(1); expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain); }); it('should call login with EU domain when region is EU', async () => { redoclyClient.domain = 'eu.redocly.com'; - await handlePush( - { + await handlePush({ + argv: { upsert: true, api: 'spec.json', destination: '@org/my-api@1.0.0', branchName: 'test', }, - ConfigFixture as any - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(mockPromptClientToken).toBeCalledTimes(1); expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain); }); diff --git a/packages/cli/src/__tests__/commands/push.test.ts b/packages/cli/src/__tests__/commands/push.test.ts index 9e98d0d6..2fe8c36d 100644 --- a/packages/cli/src/__tests__/commands/push.test.ts +++ b/packages/cli/src/__tests__/commands/push.test.ts @@ -25,8 +25,8 @@ describe('push', () => { }); it('pushes definition', async () => { - await handlePush( - { + await handlePush({ + argv: { upsert: true, api: 'spec.json', destination: '@org/my-api@1.0.0', @@ -35,8 +35,9 @@ describe('push', () => { 'job-id': '123', 'batch-size': 2, }, - ConfigFixture as any - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(redoclyClient.registryApi.prepareFileUpload).toBeCalledTimes(1); expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1); @@ -55,8 +56,8 @@ describe('push', () => { }); it('fails if jobId value is an empty string', async () => { - await handlePush( - { + await handlePush({ + argv: { upsert: true, api: 'spec.json', destination: '@org/my-api@1.0.0', @@ -65,15 +66,16 @@ describe('push', () => { 'job-id': ' ', 'batch-size': 2, }, - ConfigFixture as any - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(exitWithError).toBeCalledTimes(1); }); it('fails if batchSize value is less than 2', async () => { - await handlePush( - { + await handlePush({ + argv: { upsert: true, api: 'spec.json', destination: '@org/my-api@1.0.0', @@ -82,8 +84,9 @@ describe('push', () => { 'job-id': '123', 'batch-size': 1, }, - ConfigFixture as any - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(exitWithError).toBeCalledTimes(1); }); @@ -95,16 +98,17 @@ describe('push', () => { return { isDirectory: () => false, size: 10 }; }); - await handlePush( - { + await handlePush({ + argv: { upsert: true, api: 'spec.json', destination: '@org/my-api@1.0.0', public: true, files: ['./resouces/1.md', './resouces/2.md'], }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(redoclyClient.registryApi.pushApi).toHaveBeenLastCalledWith({ filePaths: ['filePath', 'filePath', 'filePath'], @@ -119,8 +123,8 @@ describe('push', () => { }); it('push should fail if organization not provided', async () => { - await handlePush( - { + await handlePush({ + argv: { upsert: true, api: 'spec.json', destination: 'test@v1', @@ -129,8 +133,9 @@ describe('push', () => { 'job-id': '123', 'batch-size': 2, }, - ConfigFixture as any - ); + config: ConfigFixture as any, + version: 'cli-version', + }); expect(exitWithError).toBeCalledTimes(1); expect(exitWithError).toBeCalledWith( @@ -140,8 +145,8 @@ describe('push', () => { it('push should work with organization in config', async () => { const mockConfig = { ...ConfigFixture, organization: 'test_org' } as any; - await handlePush( - { + await handlePush({ + argv: { upsert: true, api: 'spec.json', destination: 'my-api@1.0.0', @@ -150,8 +155,9 @@ describe('push', () => { 'job-id': '123', 'batch-size': 2, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1); expect(redoclyClient.registryApi.pushApi).toHaveBeenLastCalledWith({ @@ -175,16 +181,17 @@ describe('push', () => { apis: { 'my-api@1.0.0': { root: 'path' } }, } as any; - await handlePush( - { + await handlePush({ + argv: { upsert: true, branchName: 'test', public: true, 'job-id': '123', 'batch-size': 2, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1); }); @@ -192,16 +199,17 @@ describe('push', () => { it('push should fail if apis not provided', async () => { const mockConfig = { organization: 'test_org', apis: {} } as any; - await handlePush( - { + await handlePush({ + argv: { upsert: true, branchName: 'test', public: true, 'job-id': '123', 'batch-size': 2, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(exitWithError).toBeCalledTimes(1); expect(exitWithError).toHaveBeenLastCalledWith( @@ -212,8 +220,8 @@ describe('push', () => { it('push should fail if destination not provided', async () => { const mockConfig = { organization: 'test_org', apis: {} } as any; - await handlePush( - { + await handlePush({ + argv: { upsert: true, api: 'api.yaml', branchName: 'test', @@ -221,8 +229,9 @@ describe('push', () => { 'job-id': '123', 'batch-size': 2, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(exitWithError).toBeCalledTimes(1); expect(exitWithError).toHaveBeenLastCalledWith( @@ -233,8 +242,8 @@ describe('push', () => { it('push should fail if destination format is not valid', async () => { const mockConfig = { organization: 'test_org', apis: {} } as any; - await handlePush( - { + await handlePush({ + argv: { upsert: true, destination: 'name/v1', branchName: 'test', @@ -242,8 +251,9 @@ describe('push', () => { 'job-id': '123', 'batch-size': 2, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(exitWithError).toHaveBeenCalledWith( `Destination argument value is not valid, please use the right format: ${yellow( @@ -261,8 +271,8 @@ describe('push', () => { apis: { 'my test api@v1': { root: 'path' } }, } as any; - await handlePush( - { + await handlePush({ + argv: { upsert: true, destination: 'my test api@v1', branchName: 'test', @@ -270,8 +280,9 @@ describe('push', () => { 'job-id': '123', 'batch-size': 2, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(encodeURIComponentSpy).toHaveReturnedWith('my%20test%20api'); expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1); @@ -281,130 +292,144 @@ describe('push', () => { describe('transformPush', () => { it('should adapt the existing syntax', () => { const cb = jest.fn(); - transformPush(cb)( - { + transformPush(cb)({ + argv: { apis: ['openapi.yaml', '@testing_org/main@v1'], }, - {} as any - ); - expect(cb).toBeCalledWith( - { + config: {} as any, + version: 'cli-version', + }); + expect(cb).toBeCalledWith({ + argv: { api: 'openapi.yaml', destination: '@testing_org/main@v1', }, - {} - ); + config: {}, + version: 'cli-version', + }); }); it('should adapt the existing syntax (including branchName)', () => { const cb = jest.fn(); - transformPush(cb)( - { + transformPush(cb)({ + argv: { apis: ['openapi.yaml', '@testing_org/main@v1', 'other'], }, - {} as any - ); - expect(cb).toBeCalledWith( - { + config: {} as any, + version: 'cli-version', + }); + expect(cb).toBeCalledWith({ + argv: { api: 'openapi.yaml', destination: '@testing_org/main@v1', branchName: 'other', }, - {} - ); + config: {}, + version: 'cli-version', + }); }); it('should use --branch option firstly', () => { const cb = jest.fn(); - transformPush(cb)( - { + transformPush(cb)({ + argv: { apis: ['openapi.yaml', '@testing_org/main@v1', 'other'], branch: 'priority-branch', }, - {} as any - ); - expect(cb).toBeCalledWith( - { + config: {} as any, + version: 'cli-version', + }); + expect(cb).toBeCalledWith({ + argv: { api: 'openapi.yaml', destination: '@testing_org/main@v1', branchName: 'priority-branch', }, - {} - ); + config: {}, + version: 'cli-version', + }); }); it('should work for a destination only', () => { const cb = jest.fn(); - transformPush(cb)( - { + transformPush(cb)({ + argv: { apis: ['main@v1'], }, - {} as any - ); - expect(cb).toBeCalledWith( - { + config: {} as any, + version: 'cli-version', + }); + expect(cb).toBeCalledWith({ + argv: { destination: 'main@v1', }, - {} - ); + config: {}, + version: 'cli-version', + }); }); it('should work for a api only', () => { const cb = jest.fn(); - transformPush(cb)( - { + transformPush(cb)({ + argv: { apis: ['test.yaml'], }, - {} as any - ); - expect(cb).toBeCalledWith( - { + config: {} as any, + version: 'cli-version', + }); + expect(cb).toBeCalledWith({ + argv: { api: 'test.yaml', }, - {} - ); + config: {}, + version: 'cli-version', + }); }); it('should use destination from option', () => { const cb = jest.fn(); - transformPush(cb)( - { + transformPush(cb)({ + argv: { apis: ['test.yaml', 'test@v1'], destination: 'main@v1', }, - {} as any - ); - expect(cb).toBeCalledWith( - { + config: {} as any, + version: 'cli-version', + }); + expect(cb).toBeCalledWith({ + argv: { destination: 'main@v1', api: 'test.yaml', }, - {} - ); + config: {}, + version: 'cli-version', + }); }); it('should use --job-id option firstly', () => { const cb = jest.fn(); - transformPush(cb)( - { + transformPush(cb)({ + argv: { 'batch-id': 'b-123', 'job-id': 'j-123', apis: ['test'], branch: 'test', destination: 'main@v1', }, - {} as any - ); - expect(cb).toBeCalledWith( - { + config: {} as any, + version: 'cli-version', + }); + expect(cb).toBeCalledWith({ + argv: { 'job-id': 'j-123', api: 'test', branchName: 'test', destination: 'main@v1', }, - {} - ); + config: {}, + version: 'cli-version', + }); }); it('should accept no arguments at all', () => { const cb = jest.fn(); - transformPush(cb)({}, {} as any); - expect(cb).toBeCalledWith({}, {}); + transformPush(cb)({ argv: {}, config: {} as any, version: 'cli-version' }); + expect(cb).toBeCalledWith({ argv: {}, config: {}, version: 'cli-version' }); }); }); diff --git a/packages/cli/src/__tests__/wrapper.test.ts b/packages/cli/src/__tests__/wrapper.test.ts index d3554898..e1f85779 100644 --- a/packages/cli/src/__tests__/wrapper.test.ts +++ b/packages/cli/src/__tests__/wrapper.test.ts @@ -4,6 +4,7 @@ import { commandWrapper } from '../wrapper'; import { handleLint } from '../commands/lint'; import { Arguments } from 'yargs'; import { handlePush, PushOptions } from '../commands/push'; +import { detectSpec } from '@redocly/openapi-core'; jest.mock('node-fetch'); jest.mock('../utils/miscellaneous', () => ({ @@ -11,7 +12,9 @@ jest.mock('../utils/miscellaneous', () => ({ loadConfigAndHandleErrors: jest.fn(), })); jest.mock('../commands/lint', () => ({ - handleLint: jest.fn(), + handleLint: jest.fn().mockImplementation(({ collectSpecData }) => { + collectSpecData({ openapi: '3.1.0' }); + }), lintConfigCallback: jest.fn(), })); @@ -20,13 +23,32 @@ describe('commandWrapper', () => { (loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => { return { telemetry: 'on', styleguide: { recommendedFallback: true } }; }); + (detectSpec as jest.Mock).mockImplementationOnce(() => { + return 'oas3_1'; + }); process.env.REDOCLY_TELEMETRY = 'on'; const wrappedHandler = commandWrapper(handleLint); await wrappedHandler({} as any); expect(handleLint).toHaveBeenCalledTimes(1); expect(sendTelemetry).toHaveBeenCalledTimes(1); - expect(sendTelemetry).toHaveBeenCalledWith({}, 0, false); + expect(sendTelemetry).toHaveBeenCalledWith({}, 0, false, 'oas3_1', 'openapi', '3.1.0'); + }); + + it('should not collect spec version if the file is not parsed to json', async () => { + (loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => { + return { telemetry: 'on', styleguide: { recommendedFallback: true } }; + }); + (handleLint as jest.Mock).mockImplementation(({ collectSpecData }) => { + collectSpecData(); + }); + process.env.REDOCLY_TELEMETRY = 'on'; + + const wrappedHandler = commandWrapper(handleLint); + await wrappedHandler({} as any); + expect(handleLint).toHaveBeenCalledTimes(1); + expect(sendTelemetry).toHaveBeenCalledTimes(1); + expect(sendTelemetry).toHaveBeenCalledWith({}, 0, false, undefined, undefined, undefined); }); it('should NOT send telemetry if there is "telemetry: off" in the config', async () => { diff --git a/packages/cli/src/cms/commands/__tests__/push-status.test.ts b/packages/cli/src/cms/commands/__tests__/push-status.test.ts index a916485a..8bcd4cfa 100644 --- a/packages/cli/src/cms/commands/__tests__/push-status.test.ts +++ b/packages/cli/src/cms/commands/__tests__/push-status.test.ts @@ -85,15 +85,16 @@ describe('handlePushStatus()', () => { it('should throw error if organization not provided', async () => { await expect( - handlePushStatus( - { + handlePushStatus({ + argv: { domain: 'test-domain', organization: '', project: 'test-project', pushId: 'test-push-id', }, - mockConfig - ) + config: mockConfig, + version: 'cli-version', + }) ).rejects.toThrowErrorMatchingInlineSnapshot( `"No organization provided, please use --organization option or specify the 'organization' field in the config file."` ); @@ -108,15 +109,16 @@ describe('handlePushStatus()', () => { process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; remotes.getPush.mockResolvedValueOnce(pushResponseStub); - await handlePushStatus( - { + await handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', pushId: 'test-push-id', }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(process.stdout.write).toHaveBeenCalledTimes(1); expect(process.stdout.write).toHaveBeenCalledWith( '🚀 Preview deploy success.\nPreview URL: https://preview-test-url\n' @@ -127,15 +129,16 @@ describe('handlePushStatus()', () => { process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; remotes.getPush.mockResolvedValue({ ...pushResponseStub, isMainBranch: true }); - await handlePushStatus( - { + await handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', pushId: 'test-push-id', }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(process.stdout.write).toHaveBeenCalledTimes(2); expect(process.stdout.write).toHaveBeenCalledWith( '🚀 Preview deploy success.\nPreview URL: https://preview-test-url\n' @@ -157,15 +160,16 @@ describe('handlePushStatus()', () => { }); await expect( - handlePushStatus( - { + handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', pushId: 'test-push-id', }, - mockConfig - ) + config: mockConfig, + version: 'cli-version', + }) ).rejects.toThrowErrorMatchingInlineSnapshot(` "❌ Preview deploy fail. Preview URL: https://preview-test-url" @@ -197,15 +201,16 @@ describe('handlePushStatus()', () => { }, }); - await handlePushStatus( - { + await handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', pushId: 'test-push-id', }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(process.stdout.write).toHaveBeenCalledTimes(4); expect(process.stdout.write).toHaveBeenCalledWith( '🚀 Preview deploy success.\nPreview URL: https://preview-test-url\n' @@ -232,16 +237,17 @@ describe('handlePushStatus()', () => { }, }); - await handlePushStatus( - { + await handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', pushId: 'test-push-id', wait: true, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(process.stderr.write).toHaveBeenCalledWith( 'Files not added to your project. Reason: no changes.\n' @@ -253,15 +259,16 @@ describe('handlePushStatus()', () => { process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; remotes.getPush.mockResolvedValue({ ...pushResponseStub, isMainBranch: false }); - const result = await handlePushStatus( - { + const result = await handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', pushId: 'test-push-id', }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(result).toEqual({ preview: { @@ -280,15 +287,16 @@ describe('handlePushStatus()', () => { process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; remotes.getPush.mockResolvedValue({ ...pushResponseStub, isMainBranch: true }); - const result = await handlePushStatus( - { + const result = await handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', pushId: 'test-push-id', }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(result).toEqual({ preview: { @@ -344,8 +352,8 @@ describe('handlePushStatus()', () => { }, }); - const result = await handlePushStatus( - { + const result = await handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', @@ -353,8 +361,9 @@ describe('handlePushStatus()', () => { 'retry-interval': 0.5, // 500 ms wait: true, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(result).toEqual({ preview: { @@ -417,8 +426,8 @@ describe('handlePushStatus()', () => { }, }); - const result = await handlePushStatus( - { + const result = await handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', @@ -426,8 +435,9 @@ describe('handlePushStatus()', () => { 'retry-interval': 0.5, // 500 ms wait: true, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(result).toEqual({ preview: { @@ -458,16 +468,17 @@ describe('handlePushStatus()', () => { }); await expect( - handlePushStatus( - { + handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', pushId: 'test-push-id', 'continue-on-deploy-failures': false, }, - mockConfig - ) + config: mockConfig, + version: 'cli-version', + }) ).rejects.toThrowErrorMatchingInlineSnapshot(` "❌ Preview deploy fail. Preview URL: https://preview-test-url" @@ -488,16 +499,17 @@ describe('handlePushStatus()', () => { }); await expect( - handlePushStatus( - { + handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', pushId: 'test-push-id', 'continue-on-deploy-failures': true, }, - mockConfig - ) + config: mockConfig, + version: 'cli-version', + }) ).resolves.toStrictEqual({ preview: { deploy: { status: 'failed', url: 'https://preview-test-url' }, @@ -545,8 +557,8 @@ describe('handlePushStatus()', () => { const onRetrySpy = jest.fn(); - const result = await handlePushStatus( - { + const result = await handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', @@ -555,8 +567,9 @@ describe('handlePushStatus()', () => { 'retry-interval': 0.5, // 500 ms onRetry: onRetrySpy, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(onRetrySpy).toBeCalledTimes(2); @@ -617,8 +630,8 @@ describe('handlePushStatus()', () => { }); await expect( - handlePushStatus( - { + handlePushStatus({ + argv: { domain: 'test-domain', organization: 'test-org', project: 'test-project', @@ -627,8 +640,9 @@ describe('handlePushStatus()', () => { 'max-execution-time': 1, // seconds wait: true, }, - mockConfig - ) + config: mockConfig, + version: 'cli-version', + }) ).rejects.toThrowErrorMatchingInlineSnapshot(` "✗ Failed to get push status. Reason: Timeout exceeded " diff --git a/packages/cli/src/cms/commands/__tests__/push.test.ts b/packages/cli/src/cms/commands/__tests__/push.test.ts index 940db714..2ac0845d 100644 --- a/packages/cli/src/cms/commands/__tests__/push.test.ts +++ b/packages/cli/src/cms/commands/__tests__/push.test.ts @@ -63,8 +63,8 @@ describe('handlePush()', () => { pathRelativeSpy.mockImplementationOnce((_, p) => p); pathDirnameSpy.mockImplementation((_: string) => '.'); - await handlePush( - { + await handlePush({ + argv: { domain: 'test-domain', 'mount-path': 'test-mount-path', organization: 'test-org', @@ -81,8 +81,9 @@ describe('handlePush()', () => { files: ['test-file'], 'max-execution-time': 10, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(remotes.getDefaultBranch).toHaveBeenCalledWith('test-org', 'test-project'); expect(remotes.upsert).toHaveBeenCalledWith('test-org', 'test-project', { @@ -132,8 +133,8 @@ describe('handlePush()', () => { pathRelativeSpy.mockImplementationOnce((_, p) => p); pathDirnameSpy.mockImplementation((_: string) => '.'); - const result = await handlePush( - { + const result = await handlePush({ + argv: { domain: 'test-domain', 'mount-path': 'test-mount-path', organization: 'test-org', @@ -150,8 +151,9 @@ describe('handlePush()', () => { files: ['test-file'], 'max-execution-time': 10, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(result).toEqual({ pushId: 'test-id' }); }); @@ -188,8 +190,8 @@ describe('handlePush()', () => { throw new Error('Not a directory'); }); - await handlePush( - { + await handlePush({ + argv: { domain: 'test-domain', 'mount-path': 'test-mount-path', organization: 'test-org', @@ -201,8 +203,9 @@ describe('handlePush()', () => { files: ['test-folder'], 'max-execution-time': 10, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(remotes.push).toHaveBeenCalledWith( expect.anything(), @@ -230,8 +233,8 @@ describe('handlePush()', () => { const mockConfig = { apis: {} } as any; process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; - await handlePush( - { + await handlePush({ + argv: { domain: 'test-domain', 'mount-path': 'test-mount-path', organization: 'test-org', @@ -243,8 +246,9 @@ describe('handlePush()', () => { files: [], 'max-execution-time': 10, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(remotes.getDefaultBranch).not.toHaveBeenCalled(); expect(remotes.upsert).not.toHaveBeenCalled(); @@ -265,8 +269,8 @@ describe('handlePush()', () => { pathRelativeSpy.mockImplementationOnce((_, p) => p); pathDirnameSpy.mockImplementation((_: string) => '.'); - await handlePush( - { + await handlePush({ + argv: { domain: 'test-domain', 'mount-path': 'test-mount-path', project: 'test-project', @@ -277,8 +281,9 @@ describe('handlePush()', () => { 'default-branch': 'main', 'max-execution-time': 10, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(remotes.getDefaultBranch).toHaveBeenCalledWith( 'test-org-from-config', @@ -312,8 +317,8 @@ describe('handlePush()', () => { pathRelativeSpy.mockImplementationOnce((_, p) => p); pathDirnameSpy.mockImplementation((_: string) => '.'); - await handlePush( - { + await handlePush({ + argv: { 'mount-path': 'test-mount-path', project: 'test-project', branch: 'test-branch', @@ -323,8 +328,9 @@ describe('handlePush()', () => { files: ['test-file'], 'max-execution-time': 10, }, - mockConfig - ); + config: mockConfig, + version: 'cli-version', + }); expect(ReuniteApiClient).toBeCalledWith('test-domain-from-env', 'test-api-key'); }); diff --git a/packages/cli/src/cms/commands/push-status.ts b/packages/cli/src/cms/commands/push-status.ts index b52d80d0..428957d9 100644 --- a/packages/cli/src/cms/commands/push-status.ts +++ b/packages/cli/src/cms/commands/push-status.ts @@ -1,18 +1,19 @@ import * as colors from 'colorette'; -import type { Config, OutputFormat } from '@redocly/openapi-core'; - import { exitWithError, printExecutionTime } from '../../utils/miscellaneous'; import { Spinner } from '../../utils/spinner'; import { DeploymentError } from '../utils'; import { ReuniteApiClient, getApiKeys, getDomain } from '../api'; import { capitalize } from '../../utils/js-utils'; +import { retryUntilConditionMet } from './utils'; + +import type { OutputFormat } from '@redocly/openapi-core'; +import type { CommandArgs } from '../../wrapper'; import type { DeploymentStatus, DeploymentStatusResponse, PushResponse, ScorecardItem, } from '../api/types'; -import { retryUntilConditionMet } from './utils'; const RETRY_INTERVAL_MS = 5000; // 5 sec @@ -37,10 +38,10 @@ export interface PushStatusSummary { commit: PushResponse['commit']; } -export async function handlePushStatus( - argv: PushStatusOptions, - config: Config -): Promise { +export async function handlePushStatus({ + argv, + config, +}: CommandArgs): Promise { const startedAt = performance.now(); const spinner = new Spinner(); diff --git a/packages/cli/src/cms/commands/push.ts b/packages/cli/src/cms/commands/push.ts index 9fc7249b..a3b43ee6 100644 --- a/packages/cli/src/cms/commands/push.ts +++ b/packages/cli/src/cms/commands/push.ts @@ -11,7 +11,8 @@ import { import { handlePushStatus } from './push-status'; import { ReuniteApiClient, getDomain, getApiKeys } from '../api'; -import type { OutputFormat, Config } from '@redocly/openapi-core'; +import type { OutputFormat } from '@redocly/openapi-core'; +import type { CommandArgs } from '../../wrapper'; export type PushOptions = { apis?: string[]; @@ -42,10 +43,11 @@ export type PushOptions = { type FileToUpload = { name: string; path: string }; -export async function handlePush( - argv: PushOptions, - config: Config -): Promise<{ pushId: string } | void> { +export async function handlePush({ + argv, + config, + version, +}: CommandArgs): Promise<{ pushId: string } | void> { const startedAt = performance.now(); // for printing execution time const startTime = Date.now(); // for push-status command @@ -131,8 +133,8 @@ export async function handlePush( if (waitForDeployment) { process.stdout.write('\n'); - await handlePushStatus( - { + await handlePushStatus({ + argv: { organization: orgId, project: projectId, pushId: id, @@ -142,8 +144,9 @@ export async function handlePush( 'start-time': startTime, 'continue-on-deploy-failures': argv['continue-on-deploy-failures'], }, - config - ); + config, + version, + }); } verbose && printExecutionTime( diff --git a/packages/cli/src/commands/build-docs/index.ts b/packages/cli/src/commands/build-docs/index.ts index e837d44d..69977bef 100644 --- a/packages/cli/src/commands/build-docs/index.ts +++ b/packages/cli/src/commands/build-docs/index.ts @@ -2,17 +2,21 @@ import { loadAndBundleSpec } from 'redoc'; import { dirname, resolve } from 'path'; import { writeFileSync, mkdirSync } from 'fs'; import { performance } from 'perf_hooks'; - +import { getMergedConfig, isAbsoluteUrl } from '@redocly/openapi-core'; import { getObjectOrJSON, getPageHTML } from './utils'; -import type { BuildDocsArgv } from './types'; -import { Config, getMergedConfig, isAbsoluteUrl } from '@redocly/openapi-core'; import { exitWithError, getExecutionTime, getFallbackApisOrExit } from '../../utils/miscellaneous'; -export const handlerBuildCommand = async (argv: BuildDocsArgv, configFromFile: Config) => { +import type { BuildDocsArgv } from './types'; +import type { CommandArgs } from '../../wrapper'; + +export const handlerBuildCommand = async ({ + argv, + config: configFromFile, + collectSpecData, +}: CommandArgs) => { const startedAt = performance.now(); const config = getMergedConfig(configFromFile, argv.api); - const apis = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config); const { path: pathToApi } = apis[0]; @@ -31,6 +35,7 @@ export const handlerBuildCommand = async (argv: BuildDocsArgv, configFromFile: C const elapsed = getExecutionTime(startedAt); const api = await loadAndBundleSpec(isAbsoluteUrl(pathToApi) ? pathToApi : resolve(pathToApi)); + collectSpecData?.(api); const pageHTML = await getPageHTML( api, pathToApi, diff --git a/packages/cli/src/commands/bundle.ts b/packages/cli/src/commands/bundle.ts index dea5786e..ee32d728 100644 --- a/packages/cli/src/commands/bundle.ts +++ b/packages/cli/src/commands/bundle.ts @@ -1,4 +1,7 @@ -import { formatProblems, getTotals, getMergedConfig, bundle, Config } from '@redocly/openapi-core'; +import { performance } from 'perf_hooks'; +import { blue, gray, green, yellow } from 'colorette'; +import { writeFileSync } from 'fs'; +import { formatProblems, getTotals, getMergedConfig, bundle } from '@redocly/openapi-core'; import { dumpBundle, getExecutionTime, @@ -8,12 +11,11 @@ import { printUnusedWarnings, saveBundle, sortTopLevelKeysForOas, + checkForDeprecatedOptions, } from '../utils/miscellaneous'; + import type { OutputExtensions, Skips, Totals } from '../types'; -import { performance } from 'perf_hooks'; -import { blue, gray, green, yellow } from 'colorette'; -import { writeFileSync } from 'fs'; -import { checkForDeprecatedOptions } from '../utils/miscellaneous'; +import type { CommandArgs } from '../wrapper'; export type BundleOptions = { apis?: string[]; @@ -28,7 +30,12 @@ export type BundleOptions = { 'keep-url-references'?: boolean; } & Skips; -export async function handleBundle(argv: BundleOptions, config: Config, version: string) { +export async function handleBundle({ + argv, + config, + version, + collectSpecData, +}: CommandArgs) { const removeUnusedComponents = argv['remove-unused-components'] || config.rawConfig?.styleguide?.decorators?.hasOwnProperty('remove-unused-components'); @@ -59,6 +66,7 @@ export async function handleBundle(argv: BundleOptions, config: Config, version: dereference: argv.dereferenced, removeUnusedComponents, keepUrlRefs: argv['keep-url-references'], + collectSpecData, }); const fileTotals = getTotals(problems); diff --git a/packages/cli/src/commands/join.ts b/packages/cli/src/commands/join.ts index 8953fc28..04309193 100644 --- a/packages/cli/src/commands/join.ts +++ b/packages/cli/src/commands/join.ts @@ -2,7 +2,6 @@ import * as path from 'path'; import { red, blue, yellow, green } from 'colorette'; import { performance } from 'perf_hooks'; import { - Config, SpecVersion, BaseResolver, formatProblems, @@ -38,6 +37,7 @@ import type { Oas3Server, Oas3_1Definition, } from '@redocly/openapi-core/lib/typings/openapi'; +import type { CommandArgs } from '../wrapper'; const Tags = 'tags'; const xTagGroups = 'x-tagGroups'; @@ -64,7 +64,11 @@ export type JoinOptions = { 'lint-config'?: RuleSeverity; }; -export async function handleJoin(argv: JoinOptions, config: Config, packageVersion: string) { +export async function handleJoin({ + argv, + config, + version: packageVersion, +}: CommandArgs) { const startedAt = performance.now(); if (argv.apis.length < 2) { diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index acc3ae68..8030b9a0 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -1,7 +1,6 @@ import { blue, gray } from 'colorette'; import { performance } from 'perf_hooks'; import { - Config, formatProblems, getMergedConfig, getTotals, @@ -23,10 +22,11 @@ import { } from '../utils/miscellaneous'; import { getCommandNameFromArgs } from '../utils/getCommandNameFromArgs'; +import type { Arguments } from 'yargs'; import type { OutputFormat, ProblemSeverity, RuleSeverity } from '@redocly/openapi-core'; import type { RawConfigProcessor } from '@redocly/openapi-core/lib/config'; import type { CommandOptions, Skips, Totals } from '../types'; -import type { Arguments } from 'yargs'; +import type { CommandArgs } from '../wrapper'; export type LintOptions = { apis?: string[]; @@ -38,7 +38,12 @@ export type LintOptions = { 'lint-config'?: RuleSeverity; } & Omit; -export async function handleLint(argv: LintOptions, config: Config, version: string) { +export async function handleLint({ + argv, + config, + version, + collectSpecData, +}: CommandArgs) { const apis = await getFallbackApisOrExit(argv.apis, config); if (!apis.length) { @@ -74,6 +79,7 @@ export async function handleLint(argv: LintOptions, config: Config, version: str const results = await lint({ ref: path, config: resolvedConfig, + collectSpecData, }); const fileTotals = getTotals(results); diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index 14d1c1d7..8b5c89d6 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -1,7 +1,9 @@ -import { Region, RedoclyClient, Config } from '@redocly/openapi-core'; +import { Region, RedoclyClient } from '@redocly/openapi-core'; import { blue, green, gray } from 'colorette'; import { promptUser } from '../utils/miscellaneous'; +import type { CommandArgs } from '../wrapper'; + export function promptClientToken(domain: string) { return promptUser( green( @@ -17,7 +19,7 @@ export type LoginOptions = { config?: string; }; -export async function handleLogin(argv: LoginOptions, config: Config) { +export async function handleLogin({ argv, config }: CommandArgs) { const region = argv.region || config.region; const client = new RedoclyClient(region); const clientToken = await promptClientToken(client.domain); diff --git a/packages/cli/src/commands/preview-docs/index.ts b/packages/cli/src/commands/preview-docs/index.ts index 896e12cc..9afc0d63 100644 --- a/packages/cli/src/commands/preview-docs/index.ts +++ b/packages/cli/src/commands/preview-docs/index.ts @@ -7,7 +7,9 @@ import { loadConfigAndHandleErrors, } from '../../utils/miscellaneous'; import startPreviewServer from './preview-server/preview-server'; + import type { Skips } from '../../types'; +import type { CommandArgs } from '../../wrapper'; export type PreviewDocsOptions = { port: number; @@ -18,7 +20,10 @@ export type PreviewDocsOptions = { force?: boolean; } & Omit; -export async function previewDocs(argv: PreviewDocsOptions, configFromFile: Config) { +export async function previewDocs({ + argv, + config: configFromFile, +}: CommandArgs) { let isAuthorizedWithRedocly = false; let redocOptions: any = {}; let config = await reloadConfig(configFromFile); diff --git a/packages/cli/src/commands/preview-project/index.ts b/packages/cli/src/commands/preview-project/index.ts index e86a6308..cc32bf63 100644 --- a/packages/cli/src/commands/preview-project/index.ts +++ b/packages/cli/src/commands/preview-project/index.ts @@ -4,12 +4,13 @@ import { spawn } from 'child_process'; import { PRODUCT_NAMES, PRODUCT_PACKAGES } from './constants'; import type { PreviewProjectOptions, Product } from './types'; +import type { CommandArgs } from '../../wrapper'; -export const previewProject = async (args: PreviewProjectOptions) => { - const { plan, port } = args; - const projectDir = args['source-dir']; +export const previewProject = async ({ argv }: CommandArgs) => { + const { plan, port } = argv; + const projectDir = argv['source-dir']; - const product = args.product || tryGetProductFromPackageJson(projectDir); + const product = argv.product || tryGetProductFromPackageJson(projectDir); if (!isValidProduct(product)) { process.stderr.write(`Invalid product ${product}`); diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts index b34ac087..f3def883 100644 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -26,6 +26,8 @@ import { import { promptClientToken } from './login'; import { handlePush as handleCMSPush } from '../cms/commands/push'; +import type { CommandArgs } from '../wrapper'; + const DEFAULT_VERSION = 'latest'; export const DESTINATION_REGEX = @@ -59,7 +61,7 @@ export function commonPushHandler({ return transformPush(handlePush); } -export async function handlePush(argv: PushOptions, config: Config): Promise { +export async function handlePush({ argv, config }: CommandArgs): Promise { const client = new RedoclyClient(config.region); const isAuthorized = await client.isAuthorizedWithRedoclyByRegion(); if (!isAuthorized) { @@ -366,16 +368,11 @@ type BarePushArgs = Omit & { export const transformPush = (callback: typeof handlePush) => - ( - { - apis, - branch, - 'batch-id': batchId, - 'job-id': jobId, - ...rest - }: BarePushArgs & { 'batch-id'?: string }, - config: Config - ) => { + ({ + argv: { apis, branch, 'batch-id': batchId, 'job-id': jobId, ...rest }, + config, + version, + }: CommandArgs) => { const [maybeApiOrDestination, maybeDestination, maybeBranchName] = apis || []; if (batchId) { @@ -414,16 +411,17 @@ export const transformPush = apiFile = maybeApiOrDestination; } - return callback( - { + return callback({ + argv: { ...rest, destination: rest.destination ?? destination, api: apiFile, branchName: branch ?? maybeBranchName, 'job-id': jobId || batchId, }, - config - ); + config, + version, + }); }; export function getApiRoot({ diff --git a/packages/cli/src/commands/split/__tests__/index.test.ts b/packages/cli/src/commands/split/__tests__/index.test.ts index b29e5bc2..88ff1534 100644 --- a/packages/cli/src/commands/split/__tests__/index.test.ts +++ b/packages/cli/src/commands/split/__tests__/index.test.ts @@ -3,6 +3,9 @@ import * as path from 'path'; import * as openapiCore from '@redocly/openapi-core'; import { ComponentsFiles } from '../types'; import { blue, green } from 'colorette'; +import { loadConfigAndHandleErrors } from '../../../utils/__mocks__/miscellaneous'; + +import type { Config } from '@redocly/openapi-core'; const utils = require('../../../utils/miscellaneous'); @@ -25,9 +28,13 @@ describe('#split', () => { jest.spyOn(process.stderr, 'write').mockImplementation(() => true); await handleSplit({ - api: filePath, - outDir: openapiDir, - separator: '_', + argv: { + api: filePath, + outDir: openapiDir, + separator: '_', + }, + config: loadConfigAndHandleErrors() as any as Config, + version: 'cli-version', }); expect(process.stderr.write).toBeCalledTimes(2); @@ -46,9 +53,13 @@ describe('#split', () => { jest.spyOn(utils, 'pathToFilename').mockImplementation(() => 'newFilePath'); await handleSplit({ - api: filePath, - outDir: openapiDir, - separator: '_', + argv: { + api: filePath, + outDir: openapiDir, + separator: '_', + }, + config: loadConfigAndHandleErrors() as any as Config, + version: 'cli-version', }); expect(utils.pathToFilename).toBeCalledWith(expect.anything(), '_'); diff --git a/packages/cli/src/commands/split/index.ts b/packages/cli/src/commands/split/index.ts index bd0951f5..49ea5749 100644 --- a/packages/cli/src/commands/split/index.ts +++ b/packages/cli/src/commands/split/index.ts @@ -36,6 +36,7 @@ import type { Oas3PathItem, Referenced, } from './types'; +import type { CommandArgs } from '../../wrapper'; export type SplitOptions = { api: string; @@ -44,12 +45,13 @@ export type SplitOptions = { config?: string; }; -export async function handleSplit(argv: SplitOptions) { +export async function handleSplit({ argv, collectSpecData }: CommandArgs) { const startedAt = performance.now(); const { api, outDir, separator } = argv; validateDefinitionFileName(api!); const ext = getAndValidateFileExtension(api); const openapi = readYaml(api!) as Oas3Definition | Oas3_1Definition; + collectSpecData?.(openapi); splitDefinition(openapi, outDir, separator, ext); process.stderr.write( `🪓 Document: ${blue(api!)} ${green('is successfully split')} @@ -292,7 +294,7 @@ function iteratePathItems( for (const pathName of Object.keys(pathItems)) { const pathFile = `${path.join(outDir, pathToFilename(pathName, pathSeparator))}.${ext}`; - const pathData = pathItems[pathName] as Oas3PathItem; + const pathData = pathItems[pathName]; if (isRef(pathData)) continue; diff --git a/packages/cli/src/commands/stats.ts b/packages/cli/src/commands/stats.ts index b6af03df..84e438d4 100755 --- a/packages/cli/src/commands/stats.ts +++ b/packages/cli/src/commands/stats.ts @@ -1,7 +1,6 @@ import { performance } from 'perf_hooks'; import * as colors from 'colorette'; import { - Config, StyleguideConfig, normalizeTypes, BaseResolver, @@ -15,7 +14,9 @@ import { } from '@redocly/openapi-core'; import { getFallbackApisOrExit } from '../utils/miscellaneous'; import { printExecutionTime } from '../utils/miscellaneous'; + import type { StatsAccumulator, StatsName, WalkContext, OutputFormat } from '@redocly/openapi-core'; +import type { CommandArgs } from '../wrapper'; const statsAccumulator: StatsAccumulator = { refs: { metric: '🚗 References', total: 0, color: 'red', items: new Set() }, @@ -86,10 +87,11 @@ export type StatsOptions = { config?: string; }; -export async function handleStats(argv: StatsOptions, config: Config) { +export async function handleStats({ argv, config, collectSpecData }: CommandArgs) { const [{ path }] = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config); const externalRefResolver = new BaseResolver(config.resolve); const { bundle: document } = await bundle({ config, ref: path }); + collectSpecData?.(document.parsed); const lintConfig: StyleguideConfig = config.styleguide; const specVersion = detectSpec(document.parsed); const types = normalizeTypes( diff --git a/packages/cli/src/utils/miscellaneous.ts b/packages/cli/src/utils/miscellaneous.ts index 2e6a42fb..9b597070 100644 --- a/packages/cli/src/utils/miscellaneous.ts +++ b/packages/cli/src/utils/miscellaneous.ts @@ -541,7 +541,10 @@ export function cleanColors(input: string): string { export async function sendTelemetry( argv: Arguments | undefined, exit_code: ExitCode, - has_config: boolean | undefined + has_config: boolean | undefined, + spec_version: string | undefined, + spec_keyword: string | undefined, + spec_full_version: string | undefined ): Promise { try { if (!argv) { @@ -569,6 +572,9 @@ export async function sendTelemetry( environment_ci: process.env.CI, raw_input: cleanRawInput(process.argv.slice(2)), has_config, + spec_version, + spec_keyword, + spec_full_version, }; await fetch(`https://api.redocly.com/registry/telemetry/cli`, { method: 'POST', @@ -598,6 +604,9 @@ export type Analytics = { environment_ci?: string; raw_input: string; has_config?: boolean; + spec_version?: string; + spec_keyword?: string; + spec_full_version?: string; }; function isFile(value: string) { diff --git a/packages/cli/src/wrapper.ts b/packages/cli/src/wrapper.ts index 60df5b25..df27a116 100644 --- a/packages/cli/src/wrapper.ts +++ b/packages/cli/src/wrapper.ts @@ -1,22 +1,48 @@ -import { Config, Region, doesYamlFileExist } from '@redocly/openapi-core'; -import type { Arguments } from 'yargs'; +import { detectSpec, doesYamlFileExist } from '@redocly/openapi-core'; +import { isPlainObject } from '@redocly/openapi-core/lib/utils'; import { version } from './utils/update-version-notifier'; -import { - ExitCode, - exitWithError, - loadConfigAndHandleErrors, - sendTelemetry, -} from './utils/miscellaneous'; +import { exitWithError, loadConfigAndHandleErrors, sendTelemetry } from './utils/miscellaneous'; import { lintConfigCallback } from './commands/lint'; + +import type { Arguments } from 'yargs'; +import type { Config, Region } from '@redocly/openapi-core'; +import type { CollectFn } from '@redocly/openapi-core/lib/utils'; +import type { ExitCode } from './utils/miscellaneous'; import type { CommandOptions } from './types'; +export type CommandArgs = { + argv: T; + config: Config; + version: string; + collectSpecData?: CollectFn; +}; + export function commandWrapper( - commandHandler?: (argv: T, config: Config, version: string) => Promise + commandHandler?: (wrapperArgs: CommandArgs) => Promise ) { return async (argv: Arguments) => { let code: ExitCode = 2; let hasConfig; let telemetry; + let specVersion: string | undefined; + let specKeyword: string | undefined; + let specFullVersion: string | undefined; + const collectSpecData: CollectFn = (document) => { + specVersion = detectSpec(document); + if (!isPlainObject(document)) return; + specKeyword = document?.openapi + ? 'openapi' + : document?.swagger + ? 'swagger' + : document?.asyncapi + ? 'asyncapi' + : document?.arazzo + ? 'arazzo' + : undefined; + if (specKeyword) { + specFullVersion = document[specKeyword] as string; + } + }; try { if (argv.config && !doesYamlFileExist(argv.config)) { exitWithError('Please provide a valid path to the configuration file.'); @@ -32,14 +58,14 @@ export function commandWrapper( hasConfig = !config.styleguide.recommendedFallback; code = 1; if (typeof commandHandler === 'function') { - await commandHandler(argv, config, version); + await commandHandler({ argv, config, version, collectSpecData }); } code = 0; } catch (err) { // Do nothing } finally { if (process.env.REDOCLY_TELEMETRY !== 'off' && telemetry !== 'off') { - await sendTelemetry(argv, code, hasConfig); + await sendTelemetry(argv, code, hasConfig, specVersion, specKeyword, specFullVersion); } process.once('beforeExit', () => { process.exit(code); diff --git a/packages/core/src/bundle.ts b/packages/core/src/bundle.ts index 99280185..848f8e23 100755 --- a/packages/core/src/bundle.ts +++ b/packages/core/src/bundle.ts @@ -24,6 +24,7 @@ import type { WalkContext, UserContext, ResolveResult, NormalizedProblem } from import type { Config, StyleguideConfig } from './config'; import type { OasRef } from './typings/openapi'; import type { Document, ResolvedRefMap } from './resolve'; +import type { CollectFn } from './utils'; export enum OasVersion { Version2 = 'oas2', @@ -82,6 +83,7 @@ export async function bundle( opts: { ref?: string; doc?: Document; + collectSpecData?: CollectFn; } & BundleOptions ) { const { @@ -100,6 +102,7 @@ export async function bundle( if (document instanceof Error) { throw document; } + opts.collectSpecData?.(document.parsed); return bundleDocument({ document, diff --git a/packages/core/src/lint.ts b/packages/core/src/lint.ts index b40408d7..3012332c 100755 --- a/packages/core/src/lint.ts +++ b/packages/core/src/lint.ts @@ -22,14 +22,17 @@ import type { Oas3Visitor, RuleInstanceConfig, } from './visitors'; +import type { CollectFn } from './utils'; export async function lint(opts: { ref: string; config: Config; externalRefResolver?: BaseResolver; + collectSpecData?: CollectFn; }) { const { ref, externalRefResolver = new BaseResolver(opts.config.resolve) } = opts; const document = (await externalRefResolver.resolveDocument(null, ref, true)) as Document; + opts.collectSpecData?.(document.parsed); return lintDocument({ document, diff --git a/packages/core/src/oas-types.ts b/packages/core/src/oas-types.ts index 491e0cf6..90ab032a 100644 --- a/packages/core/src/oas-types.ts +++ b/packages/core/src/oas-types.ts @@ -1,4 +1,4 @@ -import { +import type { Oas3Rule, Oas3Preprocessor, Oas2Rule, @@ -16,7 +16,7 @@ import { Oas3_1Types } from './types/oas3_1'; import { AsyncApi2Types } from './types/asyncapi2'; import { AsyncApi3Types } from './types/asyncapi3'; import { ArazzoTypes } from './types/arazzo'; -import { +import type { BuiltInAsync2RuleId, BuiltInAsync3RuleId, BuiltInCommonOASRuleId, @@ -24,6 +24,7 @@ import { BuiltInOAS2RuleId, BuiltInOAS3RuleId, } from './types/redocly-yaml'; +import { isPlainObject } from './utils'; export enum SpecVersion { OAS2 = 'oas2', @@ -93,8 +94,8 @@ export type Async2DecoratorsSet = Record; export type Async3DecoratorsSet = Record; export type ArazzoDecoratorsSet = Record; -export function detectSpec(root: any): SpecVersion { - if (typeof root !== 'object') { +export function detectSpec(root: unknown): SpecVersion { + if (!isPlainObject(root)) { throw new Error(`Document must be JSON object, got ${typeof root}`); } @@ -102,11 +103,11 @@ export function detectSpec(root: any): SpecVersion { throw new Error(`Invalid OpenAPI version: should be a string but got "${typeof root.openapi}"`); } - if (root.openapi && root.openapi.startsWith('3.0')) { + if (typeof root.openapi === 'string' && root.openapi.startsWith('3.0')) { return SpecVersion.OAS3_0; } - if (root.openapi && root.openapi.startsWith('3.1')) { + if (typeof root.openapi === 'string' && root.openapi.startsWith('3.1')) { return SpecVersion.OAS3_1; } @@ -114,16 +115,15 @@ export function detectSpec(root: any): SpecVersion { return SpecVersion.OAS2; } - // if not detected yet if (root.openapi || root.swagger) { throw new Error(`Unsupported OpenAPI version: ${root.openapi || root.swagger}`); } - if (root.asyncapi && root.asyncapi.startsWith('2.')) { + if (typeof root.asyncapi === 'string' && root.asyncapi.startsWith('2.')) { return SpecVersion.Async2; } - if (root.asyncapi && root.asyncapi.startsWith('3.')) { + if (typeof root.asyncapi === 'string' && root.asyncapi.startsWith('3.')) { return SpecVersion.Async3; } @@ -131,7 +131,7 @@ export function detectSpec(root: any): SpecVersion { throw new Error(`Unsupported AsyncAPI version: ${root.asyncapi}`); } - if (root.arazzo && root.arazzo.startsWith('1.')) { + if (typeof root.arazzo === 'string' && root.arazzo.startsWith('1.')) { return SpecVersion.Arazzo; } diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 35061d68..acef7aa7 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -38,15 +38,15 @@ export function isDefined(x: T | undefined): x is T { return x !== undefined; } -export function isPlainObject(value: any): value is Record { +export function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } -export function isEmptyObject(value: any): value is Record { +export function isEmptyObject(value: unknown): value is Record { return isPlainObject(value) && Object.keys(value).length === 0; } -export function isEmptyArray(value: any) { +export function isEmptyArray(value: unknown) { return Array.isArray(value) && value.length === 0; } @@ -317,3 +317,5 @@ export function dequal(foo: any, bar: any): boolean { return foo !== foo && bar !== bar; } + +export type CollectFn = (value: unknown) => void;