From 07c053936eb4fe4810e6a5b4d19729f200b02954 Mon Sep 17 00:00:00 2001 From: Roman Sainchuk Date: Tue, 24 Sep 2024 13:53:06 +0300 Subject: [PATCH] feat: report endpoint sunset (#1677) --- .changeset/tiny-boats-check.md | 5 + .../src/cms/api/__tests__/api.client.test.ts | 195 ++++++++++++++++-- packages/cli/src/cms/api/api-client.ts | 157 +++++++++++--- .../commands/__tests__/push-status.test.ts | 3 +- .../src/cms/commands/__tests__/push.test.ts | 7 +- packages/cli/src/cms/commands/push-status.ts | 6 +- packages/cli/src/cms/commands/push.ts | 11 +- 7 files changed, 325 insertions(+), 59 deletions(-) create mode 100644 .changeset/tiny-boats-check.md diff --git a/.changeset/tiny-boats-check.md b/.changeset/tiny-boats-check.md new file mode 100644 index 00000000..8cb64ea5 --- /dev/null +++ b/.changeset/tiny-boats-check.md @@ -0,0 +1,5 @@ +--- +"@redocly/cli": patch +--- + +Added a warning message to the `push` and `push-status` commands to notify users about upcoming or ongoing resource deprecation. diff --git a/packages/cli/src/cms/api/__tests__/api.client.test.ts b/packages/cli/src/cms/api/__tests__/api.client.test.ts index f85a341e..77d4e5a3 100644 --- a/packages/cli/src/cms/api/__tests__/api.client.test.ts +++ b/packages/cli/src/cms/api/__tests__/api.client.test.ts @@ -1,7 +1,8 @@ import fetch, { Response } from 'node-fetch'; import * as FormData from 'form-data'; +import { red, yellow } from 'colorette'; -import { ReuniteApiClient, PushPayload, ReuniteApiError } from '../api-client'; +import { ReuniteApi, PushPayload, ReuniteApiError } from '../api-client'; jest.mock('node-fetch', () => ({ default: jest.fn(), @@ -21,10 +22,10 @@ describe('ApiClient', () => { const expectedUserAgent = `redocly-cli/${version} ${command}`; describe('getDefaultBranch()', () => { - let apiClient: ReuniteApiClient; + let apiClient: ReuniteApi; beforeEach(() => { - apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command }); + apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command }); }); it('should get default project branch', async () => { @@ -90,22 +91,23 @@ describe('ApiClient', () => { mountBranchName: 'remote-mount-branch-name', mountPath: 'remote-mount-path', }; - let apiClient: ReuniteApiClient; + + const responseMock = { + id: 'remote-id', + type: 'CICD', + mountPath: 'remote-mount-path', + mountBranchName: 'remote-mount-branch-name', + organizationId: testOrg, + projectId: testProject, + }; + + let apiClient: ReuniteApi; beforeEach(() => { - apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command }); + apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command }); }); it('should upsert remote', async () => { - const responseMock = { - id: 'remote-id', - type: 'CICD', - mountPath: 'remote-mount-path', - mountBranchName: 'remote-mount-branch-name', - organizationId: testOrg, - projectId: testProject, - }; - mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue(responseMock), @@ -204,10 +206,10 @@ describe('ApiClient', () => { outdated: false, }; - let apiClient: ReuniteApiClient; + let apiClient: ReuniteApi; beforeEach(() => { - apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command }); + apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command }); }); it('should push to remote', async () => { @@ -284,4 +286,165 @@ describe('ApiClient', () => { ).rejects.toThrow(new ReuniteApiError('Failed to push. Not found.', 404)); }); }); + + describe('Sunset header', () => { + const upsertRemoteMock = { + requestFn: () => + apiClient.remotes.upsert(testOrg, testProject, { + mountBranchName: 'remote-mount-branch-name', + mountPath: 'remote-mount-path', + }), + responseBody: { + id: 'remote-id', + type: 'CICD', + mountPath: 'remote-mount-path', + mountBranchName: 'remote-mount-branch-name', + organizationId: testOrg, + projectId: testProject, + }, + }; + + const getDefaultBranchMock = { + requestFn: () => apiClient.remotes.getDefaultBranch(testOrg, testProject), + responseBody: { + branchName: 'test-branch', + }, + }; + + const pushMock = { + requestFn: () => + apiClient.remotes.push( + testOrg, + testProject, + { + remoteId: 'test-remote-id', + commit: { + message: 'test-message', + author: { + name: 'test-name', + email: 'test-email', + }, + branchName: 'test-branch-name', + }, + }, + [{ path: 'some-file.yaml', stream: Buffer.from('text content') }] + ), + responseBody: { + branchName: 'rem/cicd/rem_01he7sr6ys2agb7w0g9t7978fn-main', + hasChanges: true, + files: [ + { + type: 'file', + name: 'some-file.yaml', + path: 'docs/remotes/some-file.yaml', + lastModified: 1698925132394.2993, + mimeType: 'text/yaml', + }, + ], + commitSha: 'bb23a2f8e012ac0b7b9961b57fb40d8686b21b43', + outdated: false, + }, + }; + + const endpointMocks = [upsertRemoteMock, getDefaultBranchMock, pushMock]; + + let apiClient: ReuniteApi; + + beforeEach(() => { + apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command }); + }); + + it.each(endpointMocks)( + 'should report endpoint sunset in the past', + async ({ responseBody, requestFn }) => { + jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true); + const sunsetDate = new Date('2024-09-06T12:30:32.456Z'); + + mockFetchResponse({ + ok: true, + json: jest.fn().mockResolvedValue(responseBody), + headers: new Headers({ + Sunset: sunsetDate.toISOString(), + }), + }); + + await requestFn(); + apiClient.reportSunsetWarnings(); + + expect(process.stdout.write).toHaveBeenCalledWith( + red( + `The "push" command is not compatible with your version of Redocly CLI. Update to the latest version by running "npm install @redocly/cli@latest".\n\n` + ) + ); + } + ); + + it.each(endpointMocks)( + 'should report endpoint sunset in the future', + async ({ responseBody, requestFn }) => { + jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true); + const sunsetDate = new Date(Date.now() + 1000 * 60 * 60 * 24); + + mockFetchResponse({ + ok: true, + json: jest.fn().mockResolvedValue(responseBody), + headers: new Headers({ + Sunset: sunsetDate.toISOString(), + }), + }); + + await requestFn(); + apiClient.reportSunsetWarnings(); + + expect(process.stdout.write).toHaveBeenCalledWith( + yellow( + `The "push" command will be incompatible with your version of Redocly CLI after ${sunsetDate.toLocaleString()}. Update to the latest version by running "npm install @redocly/cli@latest".\n\n` + ) + ); + } + ); + + it('should report only expired resource', async () => { + jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true); + + mockFetchResponse({ + ok: true, + json: jest.fn().mockResolvedValue(upsertRemoteMock.responseBody), + headers: new Headers({ + Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(), + }), + }); + + await upsertRemoteMock.requestFn(); + + mockFetchResponse({ + ok: true, + json: jest.fn().mockResolvedValue(getDefaultBranchMock.responseBody), + headers: new Headers({ + Sunset: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(), + }), + }); + + await getDefaultBranchMock.requestFn(); + + mockFetchResponse({ + ok: true, + json: jest.fn().mockResolvedValue(pushMock.responseBody), + headers: new Headers({ + Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(), + }), + }); + + await pushMock.requestFn(); + + apiClient.reportSunsetWarnings(); + + expect(process.stdout.write).toHaveBeenCalledTimes(1); + expect(process.stdout.write).toHaveBeenCalledWith( + red( + `The "push" command is not compatible with your version of Redocly CLI. Update to the latest version by running "npm install @redocly/cli@latest".\n\n` + ) + ); + }); + }); }); diff --git a/packages/cli/src/cms/api/api-client.ts b/packages/cli/src/cms/api/api-client.ts index bdf790bf..3cd0fa56 100644 --- a/packages/cli/src/cms/api/api-client.ts +++ b/packages/cli/src/cms/api/api-client.ts @@ -1,3 +1,4 @@ +import { yellow, red } from 'colorette'; import * as FormData from 'form-data'; import fetchWithTimeout, { type FetchWithTimeoutOptions, @@ -13,15 +14,84 @@ import type { UpsertRemoteResponse, } from './types'; +interface BaseApiClient { + request(url: string, options: FetchWithTimeoutOptions): Promise; +} +type CommandOption = 'push' | 'push-status'; +export type SunsetWarning = { sunsetDate: Date; isSunsetExpired: boolean }; +export type SunsetWarningsBuffer = SunsetWarning[]; + export class ReuniteApiError extends Error { constructor(message: string, public status: number) { super(message); } } -class ReuniteBaseApiClient { +class ReuniteApiClient implements BaseApiClient { + public sunsetWarnings: SunsetWarningsBuffer = []; + constructor(protected version: string, protected command: string) {} + public async request(url: string, options: FetchWithTimeoutOptions) { + const headers = { + ...options.headers, + 'user-agent': `redocly-cli/${this.version.trim()} ${this.command}`, + }; + + const response = await fetchWithTimeout(url, { + ...options, + headers, + }); + + this.collectSunsetWarning(response); + + return response; + } + + private collectSunsetWarning(response: Response) { + const sunsetTime = this.getSunsetDate(response); + + if (!sunsetTime) return; + + const sunsetDate = new Date(sunsetTime); + + if (sunsetTime > Date.now()) { + this.sunsetWarnings.push({ + sunsetDate, + isSunsetExpired: false, + }); + } else { + this.sunsetWarnings.push({ + sunsetDate, + isSunsetExpired: true, + }); + } + } + + private getSunsetDate(response: Response): number | undefined { + const { headers } = response; + + if (!headers) { + return; + } + + const sunsetDate = headers.get('sunset') || headers.get('Sunset'); + + if (!sunsetDate) { + return; + } + + return Date.parse(sunsetDate); + } +} + +class RemotesApi { + constructor( + private client: BaseApiClient, + private readonly domain: string, + private readonly apiKey: string + ) {} + protected async getParsedResponse(response: Response): Promise { const responseBody = await response.json(); @@ -35,32 +105,9 @@ class ReuniteBaseApiClient { ); } - protected request(url: string, options: FetchWithTimeoutOptions) { - const headers = { - ...options.headers, - 'user-agent': `redocly-cli/${this.version.trim()} ${this.command}`, - }; - - return fetchWithTimeout(url, { - ...options, - headers, - }); - } -} - -class RemotesApiClient extends ReuniteBaseApiClient { - constructor( - private readonly domain: string, - private readonly apiKey: string, - version: string, - command: string - ) { - super(version, command); - } - async getDefaultBranch(organizationId: string, projectId: string) { try { - const response = await this.request( + const response = await this.client.request( `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`, { timeout: DEFAULT_FETCH_TIMEOUT, @@ -95,7 +142,7 @@ class RemotesApiClient extends ReuniteBaseApiClient { } ): Promise { try { - const response = await this.request( + const response = await this.client.request( `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`, { timeout: DEFAULT_FETCH_TIMEOUT, @@ -150,7 +197,7 @@ class RemotesApiClient extends ReuniteBaseApiClient { payload.isMainBranch && formData.append('isMainBranch', 'true'); try { - const response = await this.request( + const response = await this.client.request( `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`, { method: 'POST', @@ -183,7 +230,7 @@ class RemotesApiClient extends ReuniteBaseApiClient { mountPath: string; }) { try { - const response = await this.request( + const response = await this.client.request( `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`, { timeout: DEFAULT_FETCH_TIMEOUT, @@ -217,7 +264,7 @@ class RemotesApiClient extends ReuniteBaseApiClient { pushId: string; }) { try { - const response = await this.request( + const response = await this.client.request( `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`, { timeout: DEFAULT_FETCH_TIMEOUT, @@ -242,8 +289,12 @@ class RemotesApiClient extends ReuniteBaseApiClient { } } -export class ReuniteApiClient { - remotes: RemotesApiClient; +export class ReuniteApi { + private apiClient: ReuniteApiClient; + private version: string; + private command: CommandOption; + + public remotes: RemotesApi; constructor({ domain, @@ -254,9 +305,49 @@ export class ReuniteApiClient { domain: string; apiKey: string; version: string; - command: 'push' | 'push-status'; + command: CommandOption; }) { - this.remotes = new RemotesApiClient(domain, apiKey, version, command); + this.command = command; + this.version = version; + this.apiClient = new ReuniteApiClient(this.version, this.command); + + this.remotes = new RemotesApi(this.apiClient, domain, apiKey); + } + + public reportSunsetWarnings(): void { + const sunsetWarnings = this.apiClient.sunsetWarnings; + + if (sunsetWarnings.length) { + const [{ isSunsetExpired, sunsetDate }] = sunsetWarnings.sort( + (a: SunsetWarning, b: SunsetWarning) => { + // First, prioritize by expiration status + if (a.isSunsetExpired !== b.isSunsetExpired) { + return a.isSunsetExpired ? -1 : 1; + } + + // If both are either expired or not, sort by sunset date + return a.sunsetDate > b.sunsetDate ? 1 : -1; + } + ); + + const updateVersionMessage = `Update to the latest version by running "npm install @redocly/cli@latest".`; + + if (isSunsetExpired) { + process.stdout.write( + red( + `The "${this.command}" command is not compatible with your version of Redocly CLI. ${updateVersionMessage}\n\n` + ) + ); + } else { + process.stdout.write( + yellow( + `The "${ + this.command + }" command will be incompatible with your version of Redocly CLI after ${sunsetDate.toLocaleString()}. ${updateVersionMessage}\n\n` + ) + ); + } + } } } 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 db02dcbc..f36a7a34 100644 --- a/packages/cli/src/cms/commands/__tests__/push-status.test.ts +++ b/packages/cli/src/cms/commands/__tests__/push-status.test.ts @@ -17,8 +17,9 @@ jest.mock('colorette', () => ({ jest.mock('../../api', () => ({ ...jest.requireActual('../../api'), - ReuniteApiClient: jest.fn().mockImplementation(function (this: any, ...args) { + ReuniteApi: jest.fn().mockImplementation(function (this: any, ...args) { this.remotes = remotes; + this.reportSunsetWarnings = jest.fn(); }), })); diff --git a/packages/cli/src/cms/commands/__tests__/push.test.ts b/packages/cli/src/cms/commands/__tests__/push.test.ts index 31870d69..d6deda23 100644 --- a/packages/cli/src/cms/commands/__tests__/push.test.ts +++ b/packages/cli/src/cms/commands/__tests__/push.test.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { handlePush } from '../push'; -import { ReuniteApiClient, ReuniteApiError } from '../../api'; +import { ReuniteApi, ReuniteApiError } from '../../api'; const remotes = { push: jest.fn(), @@ -15,8 +15,9 @@ jest.mock('@redocly/openapi-core', () => ({ jest.mock('../../api', () => ({ ...jest.requireActual('../../api'), - ReuniteApiClient: jest.fn().mockImplementation(function (this: any, ...args) { + ReuniteApi: jest.fn().mockImplementation(function (this: any, ...args) { this.remotes = remotes; + this.reportSunsetWarnings = jest.fn(); }), })); @@ -332,7 +333,7 @@ describe('handlePush()', () => { version: 'cli-version', }); - expect(ReuniteApiClient).toBeCalledWith({ + expect(ReuniteApi).toBeCalledWith({ domain: 'test-domain-from-env', apiKey: 'test-api-key', version: 'cli-version', diff --git a/packages/cli/src/cms/commands/push-status.ts b/packages/cli/src/cms/commands/push-status.ts index f819d18c..7ead6834 100644 --- a/packages/cli/src/cms/commands/push-status.ts +++ b/packages/cli/src/cms/commands/push-status.ts @@ -2,7 +2,7 @@ import * as colors from 'colorette'; import { exitWithError, printExecutionTime } from '../../utils/miscellaneous'; import { Spinner } from '../../utils/spinner'; import { DeploymentError } from '../utils'; -import { ReuniteApiClient, getApiKeys, getDomain } from '../api'; +import { ReuniteApi, getApiKeys, getDomain } from '../api'; import { capitalize } from '../../utils/js-utils'; import { handleReuniteError, retryUntilConditionMet } from './utils'; @@ -68,7 +68,7 @@ export async function handlePushStatus({ try { const apiKey = getApiKeys(domain); - const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push-status' }); + const client = new ReuniteApi({ domain, apiKey, version, command: 'push-status' }); let pushResponse: PushResponse; @@ -169,6 +169,8 @@ export async function handlePushStatus({ } printPushStatusInfo({ orgId, projectId, pushId, startedAt }); + client.reportSunsetWarnings(); + const summary: PushStatusSummary = { preview: pushResponse.status.preview, production: pushResponse.isMainBranch ? pushResponse.status.production : null, diff --git a/packages/cli/src/cms/commands/push.ts b/packages/cli/src/cms/commands/push.ts index bef249c0..0d5cee5a 100644 --- a/packages/cli/src/cms/commands/push.ts +++ b/packages/cli/src/cms/commands/push.ts @@ -5,7 +5,7 @@ import { pluralize } from '@redocly/openapi-core/lib/utils'; import { green, yellow } from 'colorette'; import { exitWithError, printExecutionTime } from '../../utils/miscellaneous'; import { handlePushStatus } from './push-status'; -import { ReuniteApiClient, getDomain, getApiKeys } from '../api'; +import { ReuniteApi, getDomain, getApiKeys } from '../api'; import { handleReuniteError } from './utils'; import type { OutputFormat } from '@redocly/openapi-core'; @@ -81,12 +81,13 @@ export async function handlePush({ const author = parseCommitAuthor(argv.author); const apiKey = getApiKeys(domain); const filesToUpload = collectFilesToPush(argv.files || argv.apis); + const commandName = 'push' as const; if (!filesToUpload.length) { - return printExecutionTime('push', startedAt, `No files to upload`); + return printExecutionTime(commandName, startedAt, `No files to upload`); } - const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push' }); + const client = new ReuniteApi({ domain, apiKey, version, command: commandName }); const projectDefaultBranch = await client.remotes.getDefaultBranch(orgId, projectId); const remote = await client.remotes.upsert(orgId, projectId, { mountBranchName: projectDefaultBranch, @@ -147,7 +148,7 @@ export async function handlePush({ } verbose && printExecutionTime( - 'push', + commandName, startedAt, `${pluralize( 'file', @@ -155,6 +156,8 @@ export async function handlePush({ )} uploaded to organization ${orgId}, project ${projectId}. Push ID: ${id}.` ); + client.reportSunsetWarnings(); + return { pushId: id, };