@redocly/cli 1.25.3 → 1.25.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
1
  import fetch, { Response } from 'node-fetch';
2
2
  import * as FormData from 'form-data';
3
+ import { red, yellow } from 'colorette';
3
4
 
4
- import { ReuniteApiClient, PushPayload, ReuniteApiError } from '../api-client';
5
+ import { ReuniteApi, PushPayload, ReuniteApiError } from '../api-client';
5
6
 
6
7
  jest.mock('node-fetch', () => ({
7
8
  default: jest.fn(),
@@ -21,10 +22,10 @@ describe('ApiClient', () => {
21
22
  const expectedUserAgent = `redocly-cli/${version} ${command}`;
22
23
 
23
24
  describe('getDefaultBranch()', () => {
24
- let apiClient: ReuniteApiClient;
25
+ let apiClient: ReuniteApi;
25
26
 
26
27
  beforeEach(() => {
27
- apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
28
+ apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
28
29
  });
29
30
 
30
31
  it('should get default project branch', async () => {
@@ -90,22 +91,23 @@ describe('ApiClient', () => {
90
91
  mountBranchName: 'remote-mount-branch-name',
91
92
  mountPath: 'remote-mount-path',
92
93
  };
93
- let apiClient: ReuniteApiClient;
94
+
95
+ const responseMock = {
96
+ id: 'remote-id',
97
+ type: 'CICD',
98
+ mountPath: 'remote-mount-path',
99
+ mountBranchName: 'remote-mount-branch-name',
100
+ organizationId: testOrg,
101
+ projectId: testProject,
102
+ };
103
+
104
+ let apiClient: ReuniteApi;
94
105
 
95
106
  beforeEach(() => {
96
- apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
107
+ apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
97
108
  });
98
109
 
99
110
  it('should upsert remote', async () => {
100
- const responseMock = {
101
- id: 'remote-id',
102
- type: 'CICD',
103
- mountPath: 'remote-mount-path',
104
- mountBranchName: 'remote-mount-branch-name',
105
- organizationId: testOrg,
106
- projectId: testProject,
107
- };
108
-
109
111
  mockFetchResponse({
110
112
  ok: true,
111
113
  json: jest.fn().mockResolvedValue(responseMock),
@@ -204,10 +206,10 @@ describe('ApiClient', () => {
204
206
  outdated: false,
205
207
  };
206
208
 
207
- let apiClient: ReuniteApiClient;
209
+ let apiClient: ReuniteApi;
208
210
 
209
211
  beforeEach(() => {
210
- apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
212
+ apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
211
213
  });
212
214
 
213
215
  it('should push to remote', async () => {
@@ -284,4 +286,165 @@ describe('ApiClient', () => {
284
286
  ).rejects.toThrow(new ReuniteApiError('Failed to push. Not found.', 404));
285
287
  });
286
288
  });
289
+
290
+ describe('Sunset header', () => {
291
+ const upsertRemoteMock = {
292
+ requestFn: () =>
293
+ apiClient.remotes.upsert(testOrg, testProject, {
294
+ mountBranchName: 'remote-mount-branch-name',
295
+ mountPath: 'remote-mount-path',
296
+ }),
297
+ responseBody: {
298
+ id: 'remote-id',
299
+ type: 'CICD',
300
+ mountPath: 'remote-mount-path',
301
+ mountBranchName: 'remote-mount-branch-name',
302
+ organizationId: testOrg,
303
+ projectId: testProject,
304
+ },
305
+ };
306
+
307
+ const getDefaultBranchMock = {
308
+ requestFn: () => apiClient.remotes.getDefaultBranch(testOrg, testProject),
309
+ responseBody: {
310
+ branchName: 'test-branch',
311
+ },
312
+ };
313
+
314
+ const pushMock = {
315
+ requestFn: () =>
316
+ apiClient.remotes.push(
317
+ testOrg,
318
+ testProject,
319
+ {
320
+ remoteId: 'test-remote-id',
321
+ commit: {
322
+ message: 'test-message',
323
+ author: {
324
+ name: 'test-name',
325
+ email: 'test-email',
326
+ },
327
+ branchName: 'test-branch-name',
328
+ },
329
+ },
330
+ [{ path: 'some-file.yaml', stream: Buffer.from('text content') }]
331
+ ),
332
+ responseBody: {
333
+ branchName: 'rem/cicd/rem_01he7sr6ys2agb7w0g9t7978fn-main',
334
+ hasChanges: true,
335
+ files: [
336
+ {
337
+ type: 'file',
338
+ name: 'some-file.yaml',
339
+ path: 'docs/remotes/some-file.yaml',
340
+ lastModified: 1698925132394.2993,
341
+ mimeType: 'text/yaml',
342
+ },
343
+ ],
344
+ commitSha: 'bb23a2f8e012ac0b7b9961b57fb40d8686b21b43',
345
+ outdated: false,
346
+ },
347
+ };
348
+
349
+ const endpointMocks = [upsertRemoteMock, getDefaultBranchMock, pushMock];
350
+
351
+ let apiClient: ReuniteApi;
352
+
353
+ beforeEach(() => {
354
+ apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
355
+ });
356
+
357
+ it.each(endpointMocks)(
358
+ 'should report endpoint sunset in the past',
359
+ async ({ responseBody, requestFn }) => {
360
+ jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
361
+ const sunsetDate = new Date('2024-09-06T12:30:32.456Z');
362
+
363
+ mockFetchResponse({
364
+ ok: true,
365
+ json: jest.fn().mockResolvedValue(responseBody),
366
+ headers: new Headers({
367
+ Sunset: sunsetDate.toISOString(),
368
+ }),
369
+ });
370
+
371
+ await requestFn();
372
+ apiClient.reportSunsetWarnings();
373
+
374
+ expect(process.stdout.write).toHaveBeenCalledWith(
375
+ red(
376
+ `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`
377
+ )
378
+ );
379
+ }
380
+ );
381
+
382
+ it.each(endpointMocks)(
383
+ 'should report endpoint sunset in the future',
384
+ async ({ responseBody, requestFn }) => {
385
+ jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
386
+ const sunsetDate = new Date(Date.now() + 1000 * 60 * 60 * 24);
387
+
388
+ mockFetchResponse({
389
+ ok: true,
390
+ json: jest.fn().mockResolvedValue(responseBody),
391
+ headers: new Headers({
392
+ Sunset: sunsetDate.toISOString(),
393
+ }),
394
+ });
395
+
396
+ await requestFn();
397
+ apiClient.reportSunsetWarnings();
398
+
399
+ expect(process.stdout.write).toHaveBeenCalledWith(
400
+ yellow(
401
+ `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`
402
+ )
403
+ );
404
+ }
405
+ );
406
+
407
+ it('should report only expired resource', async () => {
408
+ jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
409
+
410
+ mockFetchResponse({
411
+ ok: true,
412
+ json: jest.fn().mockResolvedValue(upsertRemoteMock.responseBody),
413
+ headers: new Headers({
414
+ Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(),
415
+ }),
416
+ });
417
+
418
+ await upsertRemoteMock.requestFn();
419
+
420
+ mockFetchResponse({
421
+ ok: true,
422
+ json: jest.fn().mockResolvedValue(getDefaultBranchMock.responseBody),
423
+ headers: new Headers({
424
+ Sunset: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
425
+ }),
426
+ });
427
+
428
+ await getDefaultBranchMock.requestFn();
429
+
430
+ mockFetchResponse({
431
+ ok: true,
432
+ json: jest.fn().mockResolvedValue(pushMock.responseBody),
433
+ headers: new Headers({
434
+ Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(),
435
+ }),
436
+ });
437
+
438
+ await pushMock.requestFn();
439
+
440
+ apiClient.reportSunsetWarnings();
441
+
442
+ expect(process.stdout.write).toHaveBeenCalledTimes(1);
443
+ expect(process.stdout.write).toHaveBeenCalledWith(
444
+ red(
445
+ `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`
446
+ )
447
+ );
448
+ });
449
+ });
287
450
  });
@@ -1,3 +1,4 @@
1
+ import { yellow, red } from 'colorette';
1
2
  import * as FormData from 'form-data';
2
3
  import fetchWithTimeout, {
3
4
  type FetchWithTimeoutOptions,
@@ -13,54 +14,100 @@ import type {
13
14
  UpsertRemoteResponse,
14
15
  } from './types';
15
16
 
17
+ interface BaseApiClient {
18
+ request(url: string, options: FetchWithTimeoutOptions): Promise<Response>;
19
+ }
20
+ type CommandOption = 'push' | 'push-status';
21
+ export type SunsetWarning = { sunsetDate: Date; isSunsetExpired: boolean };
22
+ export type SunsetWarningsBuffer = SunsetWarning[];
23
+
16
24
  export class ReuniteApiError extends Error {
17
25
  constructor(message: string, public status: number) {
18
26
  super(message);
19
27
  }
20
28
  }
21
29
 
22
- class ReuniteBaseApiClient {
23
- constructor(protected version: string, protected command: string) {}
24
-
25
- protected async getParsedResponse<T>(response: Response): Promise<T> {
26
- const responseBody = await response.json();
27
-
28
- if (response.ok) {
29
- return responseBody as T;
30
- }
30
+ class ReuniteApiClient implements BaseApiClient {
31
+ public sunsetWarnings: SunsetWarningsBuffer = [];
31
32
 
32
- throw new ReuniteApiError(
33
- `${responseBody.title || response.statusText || 'Unknown error'}.`,
34
- response.status
35
- );
36
- }
33
+ constructor(protected version: string, protected command: string) {}
37
34
 
38
- protected request(url: string, options: FetchWithTimeoutOptions) {
35
+ public async request(url: string, options: FetchWithTimeoutOptions) {
39
36
  const headers = {
40
37
  ...options.headers,
41
38
  'user-agent': `redocly-cli/${this.version.trim()} ${this.command}`,
42
39
  };
43
40
 
44
- return fetchWithTimeout(url, {
41
+ const response = await fetchWithTimeout(url, {
45
42
  ...options,
46
43
  headers,
47
44
  });
45
+
46
+ this.collectSunsetWarning(response);
47
+
48
+ return response;
49
+ }
50
+
51
+ private collectSunsetWarning(response: Response) {
52
+ const sunsetTime = this.getSunsetDate(response);
53
+
54
+ if (!sunsetTime) return;
55
+
56
+ const sunsetDate = new Date(sunsetTime);
57
+
58
+ if (sunsetTime > Date.now()) {
59
+ this.sunsetWarnings.push({
60
+ sunsetDate,
61
+ isSunsetExpired: false,
62
+ });
63
+ } else {
64
+ this.sunsetWarnings.push({
65
+ sunsetDate,
66
+ isSunsetExpired: true,
67
+ });
68
+ }
69
+ }
70
+
71
+ private getSunsetDate(response: Response): number | undefined {
72
+ const { headers } = response;
73
+
74
+ if (!headers) {
75
+ return;
76
+ }
77
+
78
+ const sunsetDate = headers.get('sunset') || headers.get('Sunset');
79
+
80
+ if (!sunsetDate) {
81
+ return;
82
+ }
83
+
84
+ return Date.parse(sunsetDate);
48
85
  }
49
86
  }
50
87
 
51
- class RemotesApiClient extends ReuniteBaseApiClient {
88
+ class RemotesApi {
52
89
  constructor(
90
+ private client: BaseApiClient,
53
91
  private readonly domain: string,
54
- private readonly apiKey: string,
55
- version: string,
56
- command: string
57
- ) {
58
- super(version, command);
92
+ private readonly apiKey: string
93
+ ) {}
94
+
95
+ protected async getParsedResponse<T>(response: Response): Promise<T> {
96
+ const responseBody = await response.json();
97
+
98
+ if (response.ok) {
99
+ return responseBody as T;
100
+ }
101
+
102
+ throw new ReuniteApiError(
103
+ `${responseBody.title || response.statusText || 'Unknown error'}.`,
104
+ response.status
105
+ );
59
106
  }
60
107
 
61
108
  async getDefaultBranch(organizationId: string, projectId: string) {
62
109
  try {
63
- const response = await this.request(
110
+ const response = await this.client.request(
64
111
  `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`,
65
112
  {
66
113
  timeout: DEFAULT_FETCH_TIMEOUT,
@@ -95,7 +142,7 @@ class RemotesApiClient extends ReuniteBaseApiClient {
95
142
  }
96
143
  ): Promise<UpsertRemoteResponse> {
97
144
  try {
98
- const response = await this.request(
145
+ const response = await this.client.request(
99
146
  `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`,
100
147
  {
101
148
  timeout: DEFAULT_FETCH_TIMEOUT,
@@ -150,7 +197,7 @@ class RemotesApiClient extends ReuniteBaseApiClient {
150
197
 
151
198
  payload.isMainBranch && formData.append('isMainBranch', 'true');
152
199
  try {
153
- const response = await this.request(
200
+ const response = await this.client.request(
154
201
  `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`,
155
202
  {
156
203
  method: 'POST',
@@ -183,7 +230,7 @@ class RemotesApiClient extends ReuniteBaseApiClient {
183
230
  mountPath: string;
184
231
  }) {
185
232
  try {
186
- const response = await this.request(
233
+ const response = await this.client.request(
187
234
  `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`,
188
235
  {
189
236
  timeout: DEFAULT_FETCH_TIMEOUT,
@@ -217,7 +264,7 @@ class RemotesApiClient extends ReuniteBaseApiClient {
217
264
  pushId: string;
218
265
  }) {
219
266
  try {
220
- const response = await this.request(
267
+ const response = await this.client.request(
221
268
  `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`,
222
269
  {
223
270
  timeout: DEFAULT_FETCH_TIMEOUT,
@@ -242,8 +289,12 @@ class RemotesApiClient extends ReuniteBaseApiClient {
242
289
  }
243
290
  }
244
291
 
245
- export class ReuniteApiClient {
246
- remotes: RemotesApiClient;
292
+ export class ReuniteApi {
293
+ private apiClient: ReuniteApiClient;
294
+ private version: string;
295
+ private command: CommandOption;
296
+
297
+ public remotes: RemotesApi;
247
298
 
248
299
  constructor({
249
300
  domain,
@@ -254,9 +305,49 @@ export class ReuniteApiClient {
254
305
  domain: string;
255
306
  apiKey: string;
256
307
  version: string;
257
- command: 'push' | 'push-status';
308
+ command: CommandOption;
258
309
  }) {
259
- this.remotes = new RemotesApiClient(domain, apiKey, version, command);
310
+ this.command = command;
311
+ this.version = version;
312
+ this.apiClient = new ReuniteApiClient(this.version, this.command);
313
+
314
+ this.remotes = new RemotesApi(this.apiClient, domain, apiKey);
315
+ }
316
+
317
+ public reportSunsetWarnings(): void {
318
+ const sunsetWarnings = this.apiClient.sunsetWarnings;
319
+
320
+ if (sunsetWarnings.length) {
321
+ const [{ isSunsetExpired, sunsetDate }] = sunsetWarnings.sort(
322
+ (a: SunsetWarning, b: SunsetWarning) => {
323
+ // First, prioritize by expiration status
324
+ if (a.isSunsetExpired !== b.isSunsetExpired) {
325
+ return a.isSunsetExpired ? -1 : 1;
326
+ }
327
+
328
+ // If both are either expired or not, sort by sunset date
329
+ return a.sunsetDate > b.sunsetDate ? 1 : -1;
330
+ }
331
+ );
332
+
333
+ const updateVersionMessage = `Update to the latest version by running "npm install @redocly/cli@latest".`;
334
+
335
+ if (isSunsetExpired) {
336
+ process.stdout.write(
337
+ red(
338
+ `The "${this.command}" command is not compatible with your version of Redocly CLI. ${updateVersionMessage}\n\n`
339
+ )
340
+ );
341
+ } else {
342
+ process.stdout.write(
343
+ yellow(
344
+ `The "${
345
+ this.command
346
+ }" command will be incompatible with your version of Redocly CLI after ${sunsetDate.toLocaleString()}. ${updateVersionMessage}\n\n`
347
+ )
348
+ );
349
+ }
350
+ }
260
351
  }
261
352
  }
262
353
 
@@ -17,8 +17,9 @@ jest.mock('colorette', () => ({
17
17
 
18
18
  jest.mock('../../api', () => ({
19
19
  ...jest.requireActual('../../api'),
20
- ReuniteApiClient: jest.fn().mockImplementation(function (this: any, ...args) {
20
+ ReuniteApi: jest.fn().mockImplementation(function (this: any, ...args) {
21
21
  this.remotes = remotes;
22
+ this.reportSunsetWarnings = jest.fn();
22
23
  }),
23
24
  }));
24
25
 
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { handlePush } from '../push';
4
- import { ReuniteApiClient, ReuniteApiError } from '../../api';
4
+ import { ReuniteApi, ReuniteApiError } from '../../api';
5
5
 
6
6
  const remotes = {
7
7
  push: jest.fn(),
@@ -15,8 +15,9 @@ jest.mock('@redocly/openapi-core', () => ({
15
15
 
16
16
  jest.mock('../../api', () => ({
17
17
  ...jest.requireActual('../../api'),
18
- ReuniteApiClient: jest.fn().mockImplementation(function (this: any, ...args) {
18
+ ReuniteApi: jest.fn().mockImplementation(function (this: any, ...args) {
19
19
  this.remotes = remotes;
20
+ this.reportSunsetWarnings = jest.fn();
20
21
  }),
21
22
  }));
22
23
 
@@ -332,7 +333,7 @@ describe('handlePush()', () => {
332
333
  version: 'cli-version',
333
334
  });
334
335
 
335
- expect(ReuniteApiClient).toBeCalledWith({
336
+ expect(ReuniteApi).toBeCalledWith({
336
337
  domain: 'test-domain-from-env',
337
338
  apiKey: 'test-api-key',
338
339
  version: 'cli-version',
@@ -2,7 +2,7 @@ import * as colors from 'colorette';
2
2
  import { exitWithError, printExecutionTime } from '../../utils/miscellaneous';
3
3
  import { Spinner } from '../../utils/spinner';
4
4
  import { DeploymentError } from '../utils';
5
- import { ReuniteApiClient, getApiKeys, getDomain } from '../api';
5
+ import { ReuniteApi, getApiKeys, getDomain } from '../api';
6
6
  import { capitalize } from '../../utils/js-utils';
7
7
  import { handleReuniteError, retryUntilConditionMet } from './utils';
8
8
 
@@ -68,7 +68,7 @@ export async function handlePushStatus({
68
68
 
69
69
  try {
70
70
  const apiKey = getApiKeys(domain);
71
- const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push-status' });
71
+ const client = new ReuniteApi({ domain, apiKey, version, command: 'push-status' });
72
72
 
73
73
  let pushResponse: PushResponse;
74
74
 
@@ -169,6 +169,8 @@ export async function handlePushStatus({
169
169
  }
170
170
  printPushStatusInfo({ orgId, projectId, pushId, startedAt });
171
171
 
172
+ client.reportSunsetWarnings();
173
+
172
174
  const summary: PushStatusSummary = {
173
175
  preview: pushResponse.status.preview,
174
176
  production: pushResponse.isMainBranch ? pushResponse.status.production : null,
@@ -5,7 +5,7 @@ import { pluralize } from '@redocly/openapi-core/lib/utils';
5
5
  import { green, yellow } from 'colorette';
6
6
  import { exitWithError, printExecutionTime } from '../../utils/miscellaneous';
7
7
  import { handlePushStatus } from './push-status';
8
- import { ReuniteApiClient, getDomain, getApiKeys } from '../api';
8
+ import { ReuniteApi, getDomain, getApiKeys } from '../api';
9
9
  import { handleReuniteError } from './utils';
10
10
 
11
11
  import type { OutputFormat } from '@redocly/openapi-core';
@@ -81,12 +81,13 @@ export async function handlePush({
81
81
  const author = parseCommitAuthor(argv.author);
82
82
  const apiKey = getApiKeys(domain);
83
83
  const filesToUpload = collectFilesToPush(argv.files || argv.apis);
84
+ const commandName = 'push' as const;
84
85
 
85
86
  if (!filesToUpload.length) {
86
- return printExecutionTime('push', startedAt, `No files to upload`);
87
+ return printExecutionTime(commandName, startedAt, `No files to upload`);
87
88
  }
88
89
 
89
- const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push' });
90
+ const client = new ReuniteApi({ domain, apiKey, version, command: commandName });
90
91
  const projectDefaultBranch = await client.remotes.getDefaultBranch(orgId, projectId);
91
92
  const remote = await client.remotes.upsert(orgId, projectId, {
92
93
  mountBranchName: projectDefaultBranch,
@@ -147,7 +148,7 @@ export async function handlePush({
147
148
  }
148
149
  verbose &&
149
150
  printExecutionTime(
150
- 'push',
151
+ commandName,
151
152
  startedAt,
152
153
  `${pluralize(
153
154
  'file',
@@ -155,6 +156,8 @@ export async function handlePush({
155
156
  )} uploaded to organization ${orgId}, project ${projectId}. Push ID: ${id}.`
156
157
  );
157
158
 
159
+ client.reportSunsetWarnings();
160
+
158
161
  return {
159
162
  pushId: id,
160
163
  };