feat: report endpoint sunset (#1677)

This commit is contained in:
Roman Sainchuk
2024-09-24 13:53:06 +03:00
committed by GitHub
parent a324686be3
commit 1fc5471add
7 changed files with 325 additions and 59 deletions

View File

@@ -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.

View File

@@ -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,13 +91,7 @@ describe('ApiClient', () => {
mountBranchName: 'remote-mount-branch-name',
mountPath: 'remote-mount-path',
};
let apiClient: ReuniteApiClient;
beforeEach(() => {
apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
});
it('should upsert remote', async () => {
const responseMock = {
id: 'remote-id',
type: 'CICD',
@@ -106,6 +101,13 @@ describe('ApiClient', () => {
projectId: testProject,
};
let apiClient: ReuniteApi;
beforeEach(() => {
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
});
it('should upsert remote', async () => {
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`
)
);
});
});
});

View File

@@ -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<Response>;
}
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<T>(response: Response): Promise<T> {
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<UpsertRemoteResponse> {
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`
)
);
}
}
}
}

View File

@@ -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();
}),
}));

View File

@@ -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',

View File

@@ -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,

View File

@@ -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,
};