mirror of
https://github.com/LukeHagar/redocly-cli.git
synced 2025-12-06 04:21:09 +00:00
chore: send user-agent to Reunite (#1676)
This commit is contained in:
@@ -1,23 +1,53 @@
|
|||||||
import AbortController from 'abort-controller';
|
import AbortController from 'abort-controller';
|
||||||
import fetchWithTimeout from '../utils/fetch-with-timeout';
|
import fetchWithTimeout from '../utils/fetch-with-timeout';
|
||||||
import nodeFetch from 'node-fetch';
|
import nodeFetch from 'node-fetch';
|
||||||
|
import { getProxyAgent } from '@redocly/openapi-core';
|
||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
|
|
||||||
jest.mock('node-fetch');
|
jest.mock('node-fetch');
|
||||||
|
jest.mock('@redocly/openapi-core');
|
||||||
|
|
||||||
describe('fetchWithTimeout', () => {
|
describe('fetchWithTimeout', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
// @ts-ignore
|
||||||
|
global.setTimeout = jest.fn();
|
||||||
|
global.clearTimeout = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(getProxyAgent as jest.Mock).mockReturnValueOnce(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call node-fetch with signal', async () => {
|
it('should call node-fetch with signal', async () => {
|
||||||
// @ts-ignore
|
await fetchWithTimeout('url', { timeout: 1000 });
|
||||||
global.setTimeout = jest.fn();
|
|
||||||
|
|
||||||
global.clearTimeout = jest.fn();
|
|
||||||
await fetchWithTimeout('url');
|
|
||||||
|
|
||||||
expect(global.setTimeout).toHaveBeenCalledTimes(1);
|
expect(global.setTimeout).toHaveBeenCalledTimes(1);
|
||||||
expect(nodeFetch).toHaveBeenCalledWith('url', { signal: new AbortController().signal });
|
expect(nodeFetch).toHaveBeenCalledWith('url', {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
agent: undefined,
|
||||||
|
});
|
||||||
expect(global.clearTimeout).toHaveBeenCalledTimes(1);
|
expect(global.clearTimeout).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call node-fetch with proxy agent', async () => {
|
||||||
|
(getProxyAgent as jest.Mock).mockRestore();
|
||||||
|
const proxyAgent = new HttpsProxyAgent('http://localhost');
|
||||||
|
(getProxyAgent as jest.Mock).mockReturnValueOnce(proxyAgent);
|
||||||
|
|
||||||
|
await fetchWithTimeout('url');
|
||||||
|
|
||||||
|
expect(nodeFetch).toHaveBeenCalledWith('url', { agent: proxyAgent });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call node-fetch without signal when timeout is not passed', async () => {
|
||||||
|
await fetchWithTimeout('url');
|
||||||
|
|
||||||
|
expect(global.setTimeout).not.toHaveBeenCalled();
|
||||||
|
expect(nodeFetch).toHaveBeenCalledWith('url', { agent: undefined });
|
||||||
|
expect(global.clearTimeout).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fetch, { Response } from 'node-fetch';
|
import fetch, { Response } from 'node-fetch';
|
||||||
import * as FormData from 'form-data';
|
import * as FormData from 'form-data';
|
||||||
|
|
||||||
import { ReuniteApiClient, PushPayload } from '../api-client';
|
import { ReuniteApiClient, PushPayload, ReuniteApiError } from '../api-client';
|
||||||
|
|
||||||
jest.mock('node-fetch', () => ({
|
jest.mock('node-fetch', () => ({
|
||||||
default: jest.fn(),
|
default: jest.fn(),
|
||||||
@@ -16,12 +16,15 @@ describe('ApiClient', () => {
|
|||||||
const testDomain = 'test-domain.com';
|
const testDomain = 'test-domain.com';
|
||||||
const testOrg = 'test-org';
|
const testOrg = 'test-org';
|
||||||
const testProject = 'test-project';
|
const testProject = 'test-project';
|
||||||
|
const version = '1.0.0';
|
||||||
|
const command = 'push';
|
||||||
|
const expectedUserAgent = `redocly-cli/${version} ${command}`;
|
||||||
|
|
||||||
describe('getDefaultBranch()', () => {
|
describe('getDefaultBranch()', () => {
|
||||||
let apiClient: ReuniteApiClient;
|
let apiClient: ReuniteApiClient;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiClient = new ReuniteApiClient(testDomain, testToken);
|
apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get default project branch', async () => {
|
it('should get default project branch', async () => {
|
||||||
@@ -41,6 +44,7 @@ describe('ApiClient', () => {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${testToken}`,
|
Authorization: `Bearer ${testToken}`,
|
||||||
|
'user-agent': expectedUserAgent,
|
||||||
},
|
},
|
||||||
signal: expect.any(Object),
|
signal: expect.any(Object),
|
||||||
}
|
}
|
||||||
@@ -62,7 +66,7 @@ describe('ApiClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow(
|
await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow(
|
||||||
new Error('Failed to fetch default branch: Project source not found')
|
new ReuniteApiError('Failed to fetch default branch. Project source not found.', 404)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +80,7 @@ describe('ApiClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow(
|
await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow(
|
||||||
new Error('Failed to fetch default branch: Not found')
|
new ReuniteApiError('Failed to fetch default branch. Not found.', 404)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -89,7 +93,7 @@ describe('ApiClient', () => {
|
|||||||
let apiClient: ReuniteApiClient;
|
let apiClient: ReuniteApiClient;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiClient = new ReuniteApiClient(testDomain, testToken);
|
apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should upsert remote', async () => {
|
it('should upsert remote', async () => {
|
||||||
@@ -116,6 +120,7 @@ describe('ApiClient', () => {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${testToken}`,
|
Authorization: `Bearer ${testToken}`,
|
||||||
|
'user-agent': expectedUserAgent,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mountPath: remotePayload.mountPath,
|
mountPath: remotePayload.mountPath,
|
||||||
@@ -144,8 +149,9 @@ describe('ApiClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
|
await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
|
||||||
new Error(
|
new ReuniteApiError(
|
||||||
'Failed to upsert remote: Not allowed to mount remote outside of project content path: /docs'
|
'Failed to upsert remote. Not allowed to mount remote outside of project content path: /docs.',
|
||||||
|
403
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -153,6 +159,7 @@ describe('ApiClient', () => {
|
|||||||
it('should throw statusText error if response is not ok', async () => {
|
it('should throw statusText error if response is not ok', async () => {
|
||||||
mockFetchResponse({
|
mockFetchResponse({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
status: 404,
|
||||||
statusText: 'Not found',
|
statusText: 'Not found',
|
||||||
json: jest.fn().mockResolvedValue({
|
json: jest.fn().mockResolvedValue({
|
||||||
unknownField: 'unknown-error',
|
unknownField: 'unknown-error',
|
||||||
@@ -160,7 +167,7 @@ describe('ApiClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
|
await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
|
||||||
new Error('Failed to upsert remote: Not found')
|
new ReuniteApiError('Failed to upsert remote. Not found.', 404)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -200,7 +207,7 @@ describe('ApiClient', () => {
|
|||||||
let apiClient: ReuniteApiClient;
|
let apiClient: ReuniteApiClient;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiClient = new ReuniteApiClient(testDomain, testToken);
|
apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should push to remote', async () => {
|
it('should push to remote', async () => {
|
||||||
@@ -234,6 +241,7 @@ describe('ApiClient', () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${testToken}`,
|
Authorization: `Bearer ${testToken}`,
|
||||||
|
'user-agent': expectedUserAgent,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -258,12 +266,13 @@ describe('ApiClient', () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock)
|
apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock)
|
||||||
).rejects.toThrow(new Error('Failed to push: Cannot push to remote'));
|
).rejects.toThrow(new ReuniteApiError('Failed to push. Cannot push to remote.', 403));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw statusText error if response is not ok', async () => {
|
it('should throw statusText error if response is not ok', async () => {
|
||||||
mockFetchResponse({
|
mockFetchResponse({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
status: 404,
|
||||||
statusText: 'Not found',
|
statusText: 'Not found',
|
||||||
json: jest.fn().mockResolvedValue({
|
json: jest.fn().mockResolvedValue({
|
||||||
unknownField: 'unknown-error',
|
unknownField: 'unknown-error',
|
||||||
@@ -272,7 +281,7 @@ describe('ApiClient', () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock)
|
apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock)
|
||||||
).rejects.toThrow(new Error('Failed to push: Not found'));
|
).rejects.toThrow(new ReuniteApiError('Failed to push. Not found.', 404));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import fetch from 'node-fetch';
|
|
||||||
import * as FormData from 'form-data';
|
import * as FormData from 'form-data';
|
||||||
import { getProxyAgent } from '@redocly/openapi-core';
|
import fetchWithTimeout, {
|
||||||
import fetchWithTimeout from '../../utils/fetch-with-timeout';
|
type FetchWithTimeoutOptions,
|
||||||
|
DEFAULT_FETCH_TIMEOUT,
|
||||||
|
} from '../../utils/fetch-with-timeout';
|
||||||
|
|
||||||
import type { Response } from 'node-fetch';
|
import type { Response } from 'node-fetch';
|
||||||
import type { ReadStream } from 'fs';
|
import type { ReadStream } from 'fs';
|
||||||
@@ -12,41 +13,76 @@ import type {
|
|||||||
UpsertRemoteResponse,
|
UpsertRemoteResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
class RemotesApiClient {
|
export class ReuniteApiError extends Error {
|
||||||
constructor(private readonly domain: string, private readonly apiKey: string) {}
|
constructor(message: string, public status: number) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async getParsedResponse<T>(response: Response): Promise<T> {
|
class ReuniteBaseApiClient {
|
||||||
|
constructor(protected version: string, protected command: string) {}
|
||||||
|
|
||||||
|
protected async getParsedResponse<T>(response: Response): Promise<T> {
|
||||||
const responseBody = await response.json();
|
const responseBody = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return responseBody as T;
|
return responseBody as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(responseBody.title || response.statusText);
|
throw new ReuniteApiError(
|
||||||
|
`${responseBody.title || response.statusText || 'Unknown error'}.`,
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
async getDefaultBranch(organizationId: string, projectId: string) {
|
||||||
const response = await fetchWithTimeout(
|
|
||||||
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${this.apiKey}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw new Error(`Failed to get default branch.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const response = await this.request(
|
||||||
|
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`,
|
||||||
|
{
|
||||||
|
timeout: DEFAULT_FETCH_TIMEOUT,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const source = await this.getParsedResponse<ProjectSourceResponse>(response);
|
const source = await this.getParsedResponse<ProjectSourceResponse>(response);
|
||||||
|
|
||||||
return source.branchName;
|
return source.branchName;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to fetch default branch: ${err.message || 'Unknown error'}`);
|
const message = `Failed to fetch default branch. ${err.message}`;
|
||||||
|
|
||||||
|
if (err instanceof ReuniteApiError) {
|
||||||
|
throw new ReuniteApiError(message, err.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,31 +94,34 @@ class RemotesApiClient {
|
|||||||
mountBranchName: string;
|
mountBranchName: string;
|
||||||
}
|
}
|
||||||
): Promise<UpsertRemoteResponse> {
|
): Promise<UpsertRemoteResponse> {
|
||||||
const response = await fetchWithTimeout(
|
|
||||||
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${this.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
mountPath: remote.mountPath,
|
|
||||||
mountBranchName: remote.mountBranchName,
|
|
||||||
type: 'CICD',
|
|
||||||
autoMerge: true,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw new Error(`Failed to upsert.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const response = await this.request(
|
||||||
|
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`,
|
||||||
|
{
|
||||||
|
timeout: DEFAULT_FETCH_TIMEOUT,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
mountPath: remote.mountPath,
|
||||||
|
mountBranchName: remote.mountBranchName,
|
||||||
|
type: 'CICD',
|
||||||
|
autoMerge: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return await this.getParsedResponse<UpsertRemoteResponse>(response);
|
return await this.getParsedResponse<UpsertRemoteResponse>(response);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to upsert remote: ${err.message || 'Unknown error'}`);
|
const message = `Failed to upsert remote. ${err.message}`;
|
||||||
|
|
||||||
|
if (err instanceof ReuniteApiError) {
|
||||||
|
throw new ReuniteApiError(message, err.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,46 +149,61 @@ class RemotesApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
payload.isMainBranch && formData.append('isMainBranch', 'true');
|
payload.isMainBranch && formData.append('isMainBranch', 'true');
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.apiKey}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
agent: getProxyAgent(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const response = await this.request(
|
||||||
|
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return await this.getParsedResponse<PushResponse>(response);
|
return await this.getParsedResponse<PushResponse>(response);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to push: ${err.message || 'Unknown error'}`);
|
const message = `Failed to push. ${err.message}`;
|
||||||
|
|
||||||
|
if (err instanceof ReuniteApiError) {
|
||||||
|
throw new ReuniteApiError(message, err.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRemotesList(organizationId: string, projectId: string, mountPath: string) {
|
async getRemotesList({
|
||||||
const response = await fetchWithTimeout(
|
organizationId,
|
||||||
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`,
|
projectId,
|
||||||
{
|
mountPath,
|
||||||
method: 'GET',
|
}: {
|
||||||
headers: {
|
organizationId: string;
|
||||||
'Content-Type': 'application/json',
|
projectId: string;
|
||||||
Authorization: `Bearer ${this.apiKey}`,
|
mountPath: string;
|
||||||
},
|
}) {
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw new Error(`Failed to get remotes list.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const response = await this.request(
|
||||||
|
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`,
|
||||||
|
{
|
||||||
|
timeout: DEFAULT_FETCH_TIMEOUT,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return await this.getParsedResponse<ListRemotesResponse>(response);
|
return await this.getParsedResponse<ListRemotesResponse>(response);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to get remote list: ${err.message || 'Unknown error'}`);
|
const message = `Failed to get remote list. ${err.message}`;
|
||||||
|
|
||||||
|
if (err instanceof ReuniteApiError) {
|
||||||
|
throw new ReuniteApiError(message, err.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,25 +216,28 @@ class RemotesApiClient {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
pushId: string;
|
pushId: string;
|
||||||
}) {
|
}) {
|
||||||
const response = await fetchWithTimeout(
|
|
||||||
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${this.apiKey}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw new Error(`Failed to get push status.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const response = await this.request(
|
||||||
|
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`,
|
||||||
|
{
|
||||||
|
timeout: DEFAULT_FETCH_TIMEOUT,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return await this.getParsedResponse<PushResponse>(response);
|
return await this.getParsedResponse<PushResponse>(response);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to get push status: ${err.message || 'Unknown error'}`);
|
const message = `Failed to get push status. ${err.message}`;
|
||||||
|
|
||||||
|
if (err instanceof ReuniteApiError) {
|
||||||
|
throw new ReuniteApiError(message, err.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,8 +245,18 @@ class RemotesApiClient {
|
|||||||
export class ReuniteApiClient {
|
export class ReuniteApiClient {
|
||||||
remotes: RemotesApiClient;
|
remotes: RemotesApiClient;
|
||||||
|
|
||||||
constructor(public domain: string, private readonly apiKey: string) {
|
constructor({
|
||||||
this.remotes = new RemotesApiClient(this.domain, this.apiKey);
|
domain,
|
||||||
|
apiKey,
|
||||||
|
version,
|
||||||
|
command,
|
||||||
|
}: {
|
||||||
|
domain: string;
|
||||||
|
apiKey: string;
|
||||||
|
version: string;
|
||||||
|
command: 'push' | 'push-status';
|
||||||
|
}) {
|
||||||
|
this.remotes = new RemotesApiClient(domain, apiKey, version, command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -644,7 +644,7 @@ describe('handlePushStatus()', () => {
|
|||||||
version: 'cli-version',
|
version: 'cli-version',
|
||||||
})
|
})
|
||||||
).rejects.toThrowErrorMatchingInlineSnapshot(`
|
).rejects.toThrowErrorMatchingInlineSnapshot(`
|
||||||
"✗ Failed to get push status. Reason: Timeout exceeded
|
"✗ Failed to get push status. Reason: Timeout exceeded.
|
||||||
"
|
"
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { handlePush } from '../push';
|
import { handlePush } from '../push';
|
||||||
import { ReuniteApiClient } from '../../api';
|
import { ReuniteApiClient, ReuniteApiError } from '../../api';
|
||||||
|
|
||||||
const remotes = {
|
const remotes = {
|
||||||
push: jest.fn(),
|
push: jest.fn(),
|
||||||
@@ -332,6 +332,53 @@ describe('handlePush()', () => {
|
|||||||
version: 'cli-version',
|
version: 'cli-version',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(ReuniteApiClient).toBeCalledWith('test-domain-from-env', 'test-api-key');
|
expect(ReuniteApiClient).toBeCalledWith({
|
||||||
|
domain: 'test-domain-from-env',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
version: 'cli-version',
|
||||||
|
command: 'push',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should print error message', async () => {
|
||||||
|
const mockConfig = { apis: {} } as any;
|
||||||
|
process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
|
||||||
|
|
||||||
|
remotes.push.mockRestore();
|
||||||
|
remotes.push.mockRejectedValueOnce(new ReuniteApiError('Deprecated.', 412));
|
||||||
|
|
||||||
|
fsStatSyncSpy.mockReturnValueOnce({
|
||||||
|
isDirectory() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
pathResolveSpy.mockImplementationOnce((p) => p);
|
||||||
|
pathRelativeSpy.mockImplementationOnce((_, p) => p);
|
||||||
|
pathDirnameSpy.mockImplementation((_: string) => '.');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
handlePush({
|
||||||
|
argv: {
|
||||||
|
domain: 'test-domain',
|
||||||
|
'mount-path': 'test-mount-path',
|
||||||
|
organization: 'test-org',
|
||||||
|
project: 'test-project',
|
||||||
|
branch: 'test-branch',
|
||||||
|
namespace: 'test-namespace',
|
||||||
|
repository: 'test-repository',
|
||||||
|
'commit-sha': 'test-commit-sha',
|
||||||
|
'commit-url': 'test-commit-url',
|
||||||
|
'default-branch': 'test-branch',
|
||||||
|
'created-at': 'test-created-at',
|
||||||
|
author: 'TestAuthor <test-author@mail.com>',
|
||||||
|
message: 'Test message',
|
||||||
|
files: ['test-file'],
|
||||||
|
'max-execution-time': 10,
|
||||||
|
},
|
||||||
|
config: mockConfig,
|
||||||
|
version: 'cli-version',
|
||||||
|
})
|
||||||
|
).rejects.toThrow('✗ File upload failed. Reason: Deprecated.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe('retryUntilConditionMet()', () => {
|
|||||||
retryIntervalMs: 100,
|
retryIntervalMs: 100,
|
||||||
retryTimeoutMs: 1000,
|
retryTimeoutMs: 1000,
|
||||||
})
|
})
|
||||||
).rejects.toThrow('Timeout exceeded');
|
).rejects.toThrow('Timeout exceeded.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call "onConditionNotMet" and "onRetry" callbacks', async () => {
|
it('should call "onConditionNotMet" and "onRetry" callbacks', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Spinner } from '../../utils/spinner';
|
|||||||
import { DeploymentError } from '../utils';
|
import { DeploymentError } from '../utils';
|
||||||
import { ReuniteApiClient, getApiKeys, getDomain } from '../api';
|
import { ReuniteApiClient, getApiKeys, getDomain } from '../api';
|
||||||
import { capitalize } from '../../utils/js-utils';
|
import { capitalize } from '../../utils/js-utils';
|
||||||
import { retryUntilConditionMet } from './utils';
|
import { handleReuniteError, retryUntilConditionMet } from './utils';
|
||||||
|
|
||||||
import type { OutputFormat } from '@redocly/openapi-core';
|
import type { OutputFormat } from '@redocly/openapi-core';
|
||||||
import type { CommandArgs } from '../../wrapper';
|
import type { CommandArgs } from '../../wrapper';
|
||||||
@@ -41,7 +41,8 @@ export interface PushStatusSummary {
|
|||||||
export async function handlePushStatus({
|
export async function handlePushStatus({
|
||||||
argv,
|
argv,
|
||||||
config,
|
config,
|
||||||
}: CommandArgs<PushStatusOptions>): Promise<PushStatusSummary | undefined> {
|
version,
|
||||||
|
}: CommandArgs<PushStatusOptions>): Promise<PushStatusSummary | void> {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const spinner = new Spinner();
|
const spinner = new Spinner();
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ export async function handlePushStatus({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const apiKey = getApiKeys(domain);
|
const apiKey = getApiKeys(domain);
|
||||||
const client = new ReuniteApiClient(domain, apiKey);
|
const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push-status' });
|
||||||
|
|
||||||
let pushResponse: PushResponse;
|
let pushResponse: PushResponse;
|
||||||
|
|
||||||
@@ -178,12 +179,7 @@ export async function handlePushStatus({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
|
spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
|
||||||
|
|
||||||
const message =
|
handleReuniteError('✗ Failed to get push status.', err);
|
||||||
err instanceof DeploymentError
|
|
||||||
? err.message
|
|
||||||
: `✗ Failed to get push status. Reason: ${err.message}\n`;
|
|
||||||
exitWithError(message);
|
|
||||||
return;
|
|
||||||
} finally {
|
} finally {
|
||||||
spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
|
spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import * as path from 'path';
|
|||||||
import { slash } from '@redocly/openapi-core';
|
import { slash } from '@redocly/openapi-core';
|
||||||
import { pluralize } from '@redocly/openapi-core/lib/utils';
|
import { pluralize } from '@redocly/openapi-core/lib/utils';
|
||||||
import { green, yellow } from 'colorette';
|
import { green, yellow } from 'colorette';
|
||||||
import { exitWithError, HandledError, printExecutionTime } from '../../utils/miscellaneous';
|
import { exitWithError, printExecutionTime } from '../../utils/miscellaneous';
|
||||||
import { handlePushStatus } from './push-status';
|
import { handlePushStatus } from './push-status';
|
||||||
import { ReuniteApiClient, getDomain, getApiKeys } from '../api';
|
import { ReuniteApiClient, getDomain, getApiKeys } from '../api';
|
||||||
|
import { handleReuniteError } from './utils';
|
||||||
|
|
||||||
import type { OutputFormat } from '@redocly/openapi-core';
|
import type { OutputFormat } from '@redocly/openapi-core';
|
||||||
import type { CommandArgs } from '../../wrapper';
|
import type { CommandArgs } from '../../wrapper';
|
||||||
@@ -52,7 +53,7 @@ export async function handlePush({
|
|||||||
const orgId = organization || config.organization;
|
const orgId = organization || config.organization;
|
||||||
|
|
||||||
if (!argv.message || !argv.author || !argv.branch) {
|
if (!argv.message || !argv.author || !argv.branch) {
|
||||||
exitWithError('Error: message, author and branch are required for push to the CMS.');
|
exitWithError('Error: message, author and branch are required for push to the Reunite.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
@@ -85,7 +86,7 @@ export async function handlePush({
|
|||||||
return printExecutionTime('push', startedAt, `No files to upload`);
|
return printExecutionTime('push', startedAt, `No files to upload`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ReuniteApiClient(domain, apiKey);
|
const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push' });
|
||||||
const projectDefaultBranch = await client.remotes.getDefaultBranch(orgId, projectId);
|
const projectDefaultBranch = await client.remotes.getDefaultBranch(orgId, projectId);
|
||||||
const remote = await client.remotes.upsert(orgId, projectId, {
|
const remote = await client.remotes.upsert(orgId, projectId, {
|
||||||
mountBranchName: projectDefaultBranch,
|
mountBranchName: projectDefaultBranch,
|
||||||
@@ -158,9 +159,7 @@ export async function handlePush({
|
|||||||
pushId: id,
|
pushId: id,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
handleReuniteError('✗ File upload failed.', err);
|
||||||
err instanceof HandledError ? '' : `✗ File upload failed. Reason: ${err.message}`;
|
|
||||||
exitWithError(message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { pause } from '@redocly/openapi-core';
|
import { pause } from '@redocly/openapi-core';
|
||||||
|
import { DeploymentError } from '../utils';
|
||||||
|
import { exitWithError } from '../../utils/miscellaneous';
|
||||||
|
|
||||||
|
import type { ReuniteApiError } from '../api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function retries an operation until a condition is met or a timeout is exceeded.
|
* This function retries an operation until a condition is met or a timeout is exceeded.
|
||||||
@@ -39,7 +43,7 @@ export async function retryUntilConditionMet<T>({
|
|||||||
if (condition(result)) {
|
if (condition(result)) {
|
||||||
return result;
|
return result;
|
||||||
} else if (Date.now() - startTime > retryTimeoutMs) {
|
} else if (Date.now() - startTime > retryTimeoutMs) {
|
||||||
throw new Error('Timeout exceeded');
|
throw new Error('Timeout exceeded.');
|
||||||
} else {
|
} else {
|
||||||
onConditionNotMet?.(result);
|
onConditionNotMet?.(result);
|
||||||
await pause(retryIntervalMs);
|
await pause(retryIntervalMs);
|
||||||
@@ -50,3 +54,13 @@ export async function retryUntilConditionMet<T>({
|
|||||||
|
|
||||||
return attempt();
|
return attempt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function handleReuniteError(
|
||||||
|
message: string,
|
||||||
|
error: ReuniteApiError | DeploymentError | Error
|
||||||
|
) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof DeploymentError ? error.message : `${message} Reason: ${error.message}\n`;
|
||||||
|
|
||||||
|
return exitWithError(errorMessage);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
import nodeFetch from 'node-fetch';
|
import nodeFetch, { type RequestInit } from 'node-fetch';
|
||||||
import AbortController from 'abort-controller';
|
import AbortController from 'abort-controller';
|
||||||
import { getProxyAgent } from '@redocly/openapi-core';
|
import { getProxyAgent } from '@redocly/openapi-core';
|
||||||
|
|
||||||
const TIMEOUT = 3000;
|
export const DEFAULT_FETCH_TIMEOUT = 3000;
|
||||||
|
|
||||||
export default async (url: string, options = {}) => {
|
export type FetchWithTimeoutOptions = RequestInit & {
|
||||||
try {
|
timeout?: number;
|
||||||
const controller = new AbortController();
|
};
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
controller.abort();
|
|
||||||
}, TIMEOUT);
|
|
||||||
|
|
||||||
const res = await nodeFetch(url, {
|
export default async (url: string, { timeout, ...options }: FetchWithTimeoutOptions = {}) => {
|
||||||
signal: controller.signal,
|
if (!timeout) {
|
||||||
|
return nodeFetch(url, {
|
||||||
...options,
|
...options,
|
||||||
agent: getProxyAgent(),
|
agent: getProxyAgent(),
|
||||||
});
|
});
|
||||||
clearTimeout(timeout);
|
|
||||||
return res;
|
|
||||||
} catch (e) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
const res = await nodeFetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
...options,
|
||||||
|
agent: getProxyAgent(),
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
return res;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { deprecatedRefDocsSchema } from '@redocly/config/lib/reference-docs-conf
|
|||||||
import { outputExtensions } from '../types';
|
import { outputExtensions } from '../types';
|
||||||
import { version } from './update-version-notifier';
|
import { version } from './update-version-notifier';
|
||||||
import { DESTINATION_REGEX } from '../commands/push';
|
import { DESTINATION_REGEX } from '../commands/push';
|
||||||
import fetch from './fetch-with-timeout';
|
import fetch, { DEFAULT_FETCH_TIMEOUT } from './fetch-with-timeout';
|
||||||
|
|
||||||
import type { Arguments } from 'yargs';
|
import type { Arguments } from 'yargs';
|
||||||
import type {
|
import type {
|
||||||
@@ -569,6 +569,7 @@ export async function sendTelemetry(
|
|||||||
spec_full_version,
|
spec_full_version,
|
||||||
};
|
};
|
||||||
await fetch(`https://api.redocly.com/registry/telemetry/cli`, {
|
await fetch(`https://api.redocly.com/registry/telemetry/cli`, {
|
||||||
|
timeout: DEFAULT_FETCH_TIMEOUT,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { tmpdir } from 'os';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
|
import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
|
||||||
import { compare } from 'semver';
|
import { compare } from 'semver';
|
||||||
import fetch from './fetch-with-timeout';
|
import fetch, { DEFAULT_FETCH_TIMEOUT } from './fetch-with-timeout';
|
||||||
import { cyan, green, yellow } from 'colorette';
|
import { cyan, green, yellow } from 'colorette';
|
||||||
import { cleanColors } from './miscellaneous';
|
import { cleanColors } from './miscellaneous';
|
||||||
|
|
||||||
@@ -34,10 +34,16 @@ const isNewVersionAvailable = (current: string, latest: string) => compare(curre
|
|||||||
|
|
||||||
const getLatestVersion = async (packageName: string): Promise<string | undefined> => {
|
const getLatestVersion = async (packageName: string): Promise<string | undefined> => {
|
||||||
const latestUrl = `http://registry.npmjs.org/${packageName}/latest`;
|
const latestUrl = `http://registry.npmjs.org/${packageName}/latest`;
|
||||||
const response = await fetch(latestUrl);
|
|
||||||
if (!response) return;
|
try {
|
||||||
const info = await response.json();
|
const response = await fetch(latestUrl, { timeout: DEFAULT_FETCH_TIMEOUT });
|
||||||
return info.version;
|
const info = await response.json();
|
||||||
|
|
||||||
|
return info.version;
|
||||||
|
} catch {
|
||||||
|
// Do nothing
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cacheLatestVersion = () => {
|
export const cacheLatestVersion = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user