chore: send user-agent to Reunite (#1676)

This commit is contained in:
Roman Sainchuk
2024-09-05 16:18:10 +03:00
committed by GitHub
parent 8240c78388
commit 31edb19195
12 changed files with 329 additions and 151 deletions

View File

@@ -1,23 +1,53 @@
import AbortController from 'abort-controller';
import fetchWithTimeout from '../utils/fetch-with-timeout';
import nodeFetch from 'node-fetch';
import { getProxyAgent } from '@redocly/openapi-core';
import { HttpsProxyAgent } from 'https-proxy-agent';
jest.mock('node-fetch');
jest.mock('@redocly/openapi-core');
describe('fetchWithTimeout', () => {
beforeAll(() => {
// @ts-ignore
global.setTimeout = jest.fn();
global.clearTimeout = jest.fn();
});
beforeEach(() => {
(getProxyAgent as jest.Mock).mockReturnValueOnce(undefined);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should call node-fetch with signal', async () => {
// @ts-ignore
global.setTimeout = jest.fn();
global.clearTimeout = jest.fn();
await fetchWithTimeout('url');
await fetchWithTimeout('url', { timeout: 1000 });
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);
});
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();
});
});

View File

@@ -1,7 +1,7 @@
import fetch, { Response } from 'node-fetch';
import * as FormData from 'form-data';
import { ReuniteApiClient, PushPayload } from '../api-client';
import { ReuniteApiClient, PushPayload, ReuniteApiError } from '../api-client';
jest.mock('node-fetch', () => ({
default: jest.fn(),
@@ -16,12 +16,15 @@ describe('ApiClient', () => {
const testDomain = 'test-domain.com';
const testOrg = 'test-org';
const testProject = 'test-project';
const version = '1.0.0';
const command = 'push';
const expectedUserAgent = `redocly-cli/${version} ${command}`;
describe('getDefaultBranch()', () => {
let apiClient: ReuniteApiClient;
beforeEach(() => {
apiClient = new ReuniteApiClient(testDomain, testToken);
apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
});
it('should get default project branch', async () => {
@@ -41,6 +44,7 @@ describe('ApiClient', () => {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${testToken}`,
'user-agent': expectedUserAgent,
},
signal: expect.any(Object),
}
@@ -62,7 +66,7 @@ describe('ApiClient', () => {
});
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(
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;
beforeEach(() => {
apiClient = new ReuniteApiClient(testDomain, testToken);
apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
});
it('should upsert remote', async () => {
@@ -116,6 +120,7 @@ describe('ApiClient', () => {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${testToken}`,
'user-agent': expectedUserAgent,
},
body: JSON.stringify({
mountPath: remotePayload.mountPath,
@@ -144,8 +149,9 @@ describe('ApiClient', () => {
});
await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
new Error(
'Failed to upsert remote: Not allowed to mount remote outside of project content path: /docs'
new ReuniteApiError(
'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 () => {
mockFetchResponse({
ok: false,
status: 404,
statusText: 'Not found',
json: jest.fn().mockResolvedValue({
unknownField: 'unknown-error',
@@ -160,7 +167,7 @@ describe('ApiClient', () => {
});
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;
beforeEach(() => {
apiClient = new ReuniteApiClient(testDomain, testToken);
apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
});
it('should push to remote', async () => {
@@ -234,6 +241,7 @@ describe('ApiClient', () => {
method: 'POST',
headers: {
Authorization: `Bearer ${testToken}`,
'user-agent': expectedUserAgent,
},
})
);
@@ -258,12 +266,13 @@ describe('ApiClient', () => {
await expect(
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 () => {
mockFetchResponse({
ok: false,
status: 404,
statusText: 'Not found',
json: jest.fn().mockResolvedValue({
unknownField: 'unknown-error',
@@ -272,7 +281,7 @@ describe('ApiClient', () => {
await expect(
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));
});
});
});

View File

@@ -1,7 +1,8 @@
import fetch from 'node-fetch';
import * as FormData from 'form-data';
import { getProxyAgent } from '@redocly/openapi-core';
import fetchWithTimeout from '../../utils/fetch-with-timeout';
import fetchWithTimeout, {
type FetchWithTimeoutOptions,
DEFAULT_FETCH_TIMEOUT,
} from '../../utils/fetch-with-timeout';
import type { Response } from 'node-fetch';
import type { ReadStream } from 'fs';
@@ -12,41 +13,76 @@ import type {
UpsertRemoteResponse,
} from './types';
class RemotesApiClient {
constructor(private readonly domain: string, private readonly apiKey: string) {}
export class ReuniteApiError extends Error {
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();
if (response.ok) {
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) {
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 {
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);
return source.branchName;
} 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;
}
): 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 {
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);
} 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');
const response = await fetch(
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
body: formData,
agent: getProxyAgent(),
}
);
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);
} 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) {
const response = await fetchWithTimeout(
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
}
);
if (!response) {
throw new Error(`Failed to get remotes list.`);
}
async getRemotesList({
organizationId,
projectId,
mountPath,
}: {
organizationId: string;
projectId: string;
mountPath: string;
}) {
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);
} 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;
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 {
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);
} 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 {
remotes: RemotesApiClient;
constructor(public domain: string, private readonly apiKey: string) {
this.remotes = new RemotesApiClient(this.domain, this.apiKey);
constructor({
domain,
apiKey,
version,
command,
}: {
domain: string;
apiKey: string;
version: string;
command: 'push' | 'push-status';
}) {
this.remotes = new RemotesApiClient(domain, apiKey, version, command);
}
}

View File

@@ -644,7 +644,7 @@ describe('handlePushStatus()', () => {
version: 'cli-version',
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
"✗ Failed to get push status. Reason: Timeout exceeded
"✗ Failed to get push status. Reason: Timeout exceeded.
"
`);
});

View File

@@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { handlePush } from '../push';
import { ReuniteApiClient } from '../../api';
import { ReuniteApiClient, ReuniteApiError } from '../../api';
const remotes = {
push: jest.fn(),
@@ -332,6 +332,53 @@ describe('handlePush()', () => {
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.');
});
});

View File

@@ -32,7 +32,7 @@ describe('retryUntilConditionMet()', () => {
retryIntervalMs: 100,
retryTimeoutMs: 1000,
})
).rejects.toThrow('Timeout exceeded');
).rejects.toThrow('Timeout exceeded.');
});
it('should call "onConditionNotMet" and "onRetry" callbacks', async () => {

View File

@@ -4,7 +4,7 @@ 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 { handleReuniteError, retryUntilConditionMet } from './utils';
import type { OutputFormat } from '@redocly/openapi-core';
import type { CommandArgs } from '../../wrapper';
@@ -41,7 +41,8 @@ export interface PushStatusSummary {
export async function handlePushStatus({
argv,
config,
}: CommandArgs<PushStatusOptions>): Promise<PushStatusSummary | undefined> {
version,
}: CommandArgs<PushStatusOptions>): Promise<PushStatusSummary | void> {
const startedAt = performance.now();
const spinner = new Spinner();
@@ -67,7 +68,7 @@ export async function handlePushStatus({
try {
const apiKey = getApiKeys(domain);
const client = new ReuniteApiClient(domain, apiKey);
const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push-status' });
let pushResponse: PushResponse;
@@ -178,12 +179,7 @@ export async function handlePushStatus({
} catch (err) {
spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
const message =
err instanceof DeploymentError
? err.message
: `✗ Failed to get push status. Reason: ${err.message}\n`;
exitWithError(message);
return;
handleReuniteError('✗ Failed to get push status.', err);
} finally {
spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
}

View File

@@ -3,9 +3,10 @@ import * as path from 'path';
import { slash } from '@redocly/openapi-core';
import { pluralize } from '@redocly/openapi-core/lib/utils';
import { green, yellow } from 'colorette';
import { exitWithError, HandledError, printExecutionTime } from '../../utils/miscellaneous';
import { exitWithError, printExecutionTime } from '../../utils/miscellaneous';
import { handlePushStatus } from './push-status';
import { ReuniteApiClient, getDomain, getApiKeys } from '../api';
import { handleReuniteError } from './utils';
import type { OutputFormat } from '@redocly/openapi-core';
import type { CommandArgs } from '../../wrapper';
@@ -52,7 +53,7 @@ export async function handlePush({
const orgId = organization || config.organization;
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) {
@@ -85,7 +86,7 @@ export async function handlePush({
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 remote = await client.remotes.upsert(orgId, projectId, {
mountBranchName: projectDefaultBranch,
@@ -158,9 +159,7 @@ export async function handlePush({
pushId: id,
};
} catch (err) {
const message =
err instanceof HandledError ? '' : `✗ File upload failed. Reason: ${err.message}`;
exitWithError(message);
handleReuniteError('✗ File upload failed.', err);
}
}

View File

@@ -1,4 +1,8 @@
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.
@@ -39,7 +43,7 @@ export async function retryUntilConditionMet<T>({
if (condition(result)) {
return result;
} else if (Date.now() - startTime > retryTimeoutMs) {
throw new Error('Timeout exceeded');
throw new Error('Timeout exceeded.');
} else {
onConditionNotMet?.(result);
await pause(retryIntervalMs);
@@ -50,3 +54,13 @@ export async function retryUntilConditionMet<T>({
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);
}

View File

@@ -1,24 +1,33 @@
import nodeFetch from 'node-fetch';
import nodeFetch, { type RequestInit } from 'node-fetch';
import AbortController from 'abort-controller';
import { getProxyAgent } from '@redocly/openapi-core';
const TIMEOUT = 3000;
export const DEFAULT_FETCH_TIMEOUT = 3000;
export default async (url: string, options = {}) => {
try {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, TIMEOUT);
export type FetchWithTimeoutOptions = RequestInit & {
timeout?: number;
};
const res = await nodeFetch(url, {
signal: controller.signal,
export default async (url: string, { timeout, ...options }: FetchWithTimeoutOptions = {}) => {
if (!timeout) {
return nodeFetch(url, {
...options,
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;
};

View File

@@ -22,7 +22,7 @@ import { deprecatedRefDocsSchema } from '@redocly/config/lib/reference-docs-conf
import { outputExtensions } from '../types';
import { version } from './update-version-notifier';
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 {
@@ -569,6 +569,7 @@ export async function sendTelemetry(
spec_full_version,
};
await fetch(`https://api.redocly.com/registry/telemetry/cli`, {
timeout: DEFAULT_FETCH_TIMEOUT,
method: 'POST',
headers: {
'content-type': 'application/json',

View File

@@ -2,7 +2,7 @@ import { tmpdir } from 'os';
import { join } from 'path';
import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
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 { cleanColors } from './miscellaneous';
@@ -34,10 +34,16 @@ const isNewVersionAvailable = (current: string, latest: string) => compare(curre
const getLatestVersion = async (packageName: string): Promise<string | undefined> => {
const latestUrl = `http://registry.npmjs.org/${packageName}/latest`;
const response = await fetch(latestUrl);
if (!response) return;
const info = await response.json();
return info.version;
try {
const response = await fetch(latestUrl, { timeout: DEFAULT_FETCH_TIMEOUT });
const info = await response.json();
return info.version;
} catch {
// Do nothing
return;
}
};
export const cacheLatestVersion = () => {