Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Separating github api calls into new file and adding some tests #68

Merged
merged 4 commits into from
Dec 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions api/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import axios, { AxiosResponse } from 'axios';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the github api calls into here did a few things:

  • Made it easier to mock the api calls in the unit tests
  • Allowed me to add some comments to explain what each github api request is doing

import { DEFAULT_USER_AGENT_HEADERS } from '../http/getAxiosConfig';
import { GithubReleaseData, GithubRepoFile } from '../types/Github';

const GITHUB_REPOS_API = 'https://api.github.com/repos';
const GITHUB_RAW_CONTENT_API_PATH = 'https://raw.githubusercontent.com';

declare global {
// eslint-disable-next-line no-var
var githubToken: string;
}

type RepoPath = `${string}/${string}`;

const GITHUB_AUTH_HEADERS = {
authorization:
global && global.githubToken ? `Bearer ${global.githubToken}` : null,
};

// Returns information about the repo's releases. Defaults to "latest" if no tag is provided
// https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-a-release-by-tag-name
export async function fetchRepoReleaseData(
repoPath: RepoPath,
tag = ''
): Promise<AxiosResponse<GithubReleaseData>> {
const URL = `${GITHUB_REPOS_API}/${repoPath}/releases`;

return axios.get<GithubReleaseData>(
`${URL}/${tag ? `tags/${tag}` : 'latest'}`,
{
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
}
);
}

// Returns the entire repo content as a zip, using the zipball_url from fetchRepoReleaseData()
// https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#download-a-repository-archive-zip
export async function fetchRepoAsZip(
zipUrl: string
): Promise<AxiosResponse<Buffer>> {
return axios.get<Buffer>(zipUrl, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
});
}

// Returns the raw file contents via the raw.githubusercontent endpoint
export async function fetchRepoFile(
repoPath: RepoPath,
filePath: string,
ref: string
): Promise<AxiosResponse<Buffer>> {
return axios.get<Buffer>(
`${GITHUB_RAW_CONTENT_API_PATH}/${repoPath}/${ref}/${filePath}`,
{
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
}
);
}

// Returns the raw file contents via the raw.githubusercontent endpoint
export async function fetchRepoFileByDownloadUrl(
downloadUrl: string
): Promise<AxiosResponse<Buffer>> {
return axios.get<Buffer>(downloadUrl, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
});
}

// Returns the contents of a file or directory in a repository by path
// https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#get-repository-content
export async function fetchRepoContents(
repoPath: RepoPath,
path: string,
ref?: string
): Promise<AxiosResponse<Array<GithubRepoFile>>> {
const refQuery = ref ? `?ref=${ref}` : '';

return axios.get<Array<GithubRepoFile>>(
`${GITHUB_REPOS_API}/${repoPath}/contents/${path}${refQuery}`,
{
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
}
);
}
4 changes: 2 additions & 2 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
}
},
"github": {
"fetchJsonFromRepository": {
"fetching": "Fetching {{ url }}...",
"fetchFileFromRepository": {
"fetching": "Fetching {{ path }}...",
"errors": {
"fetchFail": "An error occured fetching JSON file."
}
Expand Down
26 changes: 26 additions & 0 deletions lib/__tests__/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { fetchFileFromRepository } from '../github';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests for the github utils will come in a follow up PR

import { fetchRepoFile as __fetchRepoFile } from '../../api/github';

jest.mock('../../api/github');

const fetchRepoFile = __fetchRepoFile as jest.MockedFunction<
typeof __fetchRepoFile
>;

describe('lib/github', () => {
describe('fetchFileFromRepository()', () => {
beforeAll(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fetchRepoFile.mockResolvedValue({ data: null } as any);
});

afterAll(() => {
fetchRepoFile.mockReset();
});

it('downloads a github repo and writes it to a destination folder', async () => {
await fetchFileFromRepository('owner/repo', 'file', 'ref');
expect(fetchRepoFile).toHaveBeenCalledWith('owner/repo', 'file', 'ref');
});
});
});
75 changes: 75 additions & 0 deletions lib/__tests__/trackUsage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import axios from 'axios';
import { trackUsage } from '../trackUsage';
import {
getAccountConfig as __getAccountConfig,
getAndLoadConfigIfNeeded as __getAndLoadConfigIfNeeded,
} from '../../config';
import { AuthType } from '../../types/Accounts';
import { ENVIRONMENTS } from '../../constants/environments';

jest.mock('axios');
jest.mock('../../config');

const mockedAxios = jest.mocked(axios);
const getAccountConfig = __getAccountConfig as jest.MockedFunction<
typeof __getAccountConfig
>;
const getAndLoadConfigIfNeeded =
__getAndLoadConfigIfNeeded as jest.MockedFunction<
typeof __getAndLoadConfigIfNeeded
>;

mockedAxios.mockResolvedValue({});
getAndLoadConfigIfNeeded.mockReturnValue({});

const account = {
accountId: 12345,
authType: 'personalaccesskey' as AuthType,
personalAccessKey: 'let-me-in-3',
auth: {
tokenInfo: {
expiresAt: '',
accessToken: 'test-token',
},
},
env: ENVIRONMENTS.QA,
};

const usageTrackingMeta = {
action: 'cli-command',
command: 'test-command',
};

describe('lib/trackUsage', () => {
describe('trackUsage()', () => {
beforeEach(() => {
mockedAxios.mockClear();
getAccountConfig.mockReset();
getAccountConfig.mockReturnValue(account);
});

it('tracks correctly for unauthenticated accounts', async () => {
await trackUsage('test-action', 'INTERACTION', usageTrackingMeta);
const requestArgs = mockedAxios.mock.lastCall
? mockedAxios.mock.lastCall[0]
: ({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any

expect(mockedAxios).toHaveBeenCalled();
expect(requestArgs!.data.eventName).toEqual('test-action');
expect(requestArgs!.url.includes('authenticated')).toBeFalsy();
expect(getAccountConfig).not.toHaveBeenCalled();
});

it('tracks correctly for authenticated accounts', async () => {
await trackUsage('test-action', 'INTERACTION', usageTrackingMeta, 12345);
const requestArgs = mockedAxios.mock.lastCall
? mockedAxios.mock.lastCall[0]
: ({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any

expect(mockedAxios).toHaveBeenCalled();
expect(requestArgs!.data.eventName).toEqual('test-action');
expect(requestArgs!.url.includes('authenticated')).toBeTruthy();
expect(getAccountConfig).toHaveBeenCalled();
});
});
});
10 changes: 5 additions & 5 deletions lib/validate.ts → lib/cms/validate.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import fs from 'fs-extra';
import { HUBL_EXTENSIONS } from '../constants/extensions';
import { validateHubl } from '../api/validateHubl';
import { walk } from './fs';
import { getExt } from './path';
import { LintResult } from '../types/HublValidation';
import { HUBL_EXTENSIONS } from '../../constants/extensions';
import { validateHubl } from '../../api/validateHubl';
import { walk } from '../fs';
import { getExt } from '../path';
import { LintResult } from '../../types/HublValidation';

export async function lint(
accountId: number,
Comment on lines 1 to 9
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validate is already being used in the CLI, so this is technically a breaking change. Don't think it's a big deal to do now, we'll just have to remember to update it in the CLI whenever we pull in the next release. I do think it makes sense for it to live under /cms

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call out, thanks for the heads up. Is the currently-published version of the CLI using it? I think we should be safe if we push this out as a minor update right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it's live on the current beta release. I think bumping the minor version should be safe

Expand Down
85 changes: 34 additions & 51 deletions lib/github.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import axios from 'axios';
import path from 'path';
import fs from 'fs-extra';

Expand All @@ -7,42 +6,37 @@ import { throwError, throwErrorWithMessage } from '../errors/standardErrors';
import { extractZipArchive } from './archive';

import { GITHUB_RELEASE_TYPES } from '../constants/github';
import { DEFAULT_USER_AGENT_HEADERS } from '../http/getAxiosConfig';
import { BaseError } from '../types/Error';
import { GithubReleaseData, GithubRepoFile } from '../types/Github';
import { ValueOf } from '../types/Utils';
import { LogCallbacksArg } from '../types/LogCallbacks';
import {
fetchRepoFile,
fetchRepoFileByDownloadUrl,
fetchRepoAsZip,
fetchRepoReleaseData,
fetchRepoContents,
} from '../api/github';

const i18nKey = 'lib.github';

declare global {
// eslint-disable-next-line no-var
var githubToken: string;
}

type RepoPath = `${string}/${string}`;

const GITHUB_AUTH_HEADERS = {
authorization:
global && global.githubToken ? `Bearer ${global.githubToken}` : null,
};

export async function fetchJsonFromRepository(
export async function fetchFileFromRepository(
repoPath: RepoPath,
filePath: string,
ref: string
): Promise<JSON> {
): Promise<Buffer> {
try {
const URL = `https://raw.githubusercontent.com/${repoPath}/${ref}/${filePath}`;
debug(`${i18nKey}.fetchJsonFromRepository.fetching`, { url: URL });

const { data } = await axios.get<JSON>(URL, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
debug(`${i18nKey}.fetchFileFromRepository.fetching`, {
path: `${repoPath}/${ref}/${filePath}`,
});

const { data } = await fetchRepoFile(repoPath, filePath, ref);
return data;
} catch (err) {
throwErrorWithMessage(
`${i18nKey}.fetchJsonFromRepository.errors.fetchFail`,
`${i18nKey}.fetchFileFromRepository.errors.fetchFail`,
{},
err as BaseError
);
Expand All @@ -57,13 +51,8 @@ export async function fetchReleaseData(
if (tag.length && tag[0] !== 'v') {
tag = `v${tag}`;
}
const URI = tag
? `https://api.github.com/repos/${repoPath}/releases/tags/${tag}`
: `https://api.github.com/repos/${repoPath}/releases/latest`;
try {
const { data } = await axios.get<GithubReleaseData>(URI, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
});
const { data } = await fetchRepoReleaseData(repoPath, tag);
return data;
} catch (err) {
const error = err as BaseError;
Expand Down Expand Up @@ -99,9 +88,7 @@ async function downloadGithubRepoZip(
const { name } = releaseData;
debug(`${i18nKey}.downloadGithubRepoZip.fetchingName`, { name });
}
const { data } = await axios.get<Buffer>(zipUrl, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
});
const { data } = await fetchRepoAsZip(zipUrl);
debug(`${i18nKey}.downloadGithubRepoZip.completed`);
return data;
} catch (err) {
Expand Down Expand Up @@ -144,29 +131,11 @@ export async function cloneGithubRepo(
return success;
}

async function getGitHubRepoContentsAtPath(
repoPath: RepoPath,
path: string,
ref?: string
): Promise<Array<GithubRepoFile>> {
const refQuery = ref ? `?ref=${ref}` : '';
const contentsRequestUrl = `https://api.github.com/repos/${repoPath}/contents/${path}${refQuery}`;

const response = await axios.get<Array<GithubRepoFile>>(contentsRequestUrl, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
});

return response.data;
}

async function fetchGitHubRepoContentFromDownloadUrl(
dest: string,
downloadUrl: string
): Promise<void> {
const resp = await axios.get<Buffer>(downloadUrl, {
headers: { ...DEFAULT_USER_AGENT_HEADERS, ...GITHUB_AUTH_HEADERS },
});

const resp = await fetchRepoFileByDownloadUrl(downloadUrl);
fs.writeFileSync(dest, resp.data, 'utf8');
}

Expand All @@ -181,7 +150,7 @@ export async function downloadGithubRepoContents(
fs.ensureDirSync(path.dirname(dest));

try {
const contentsResp = await getGitHubRepoContentsAtPath(
const { data: contentsResp } = await fetchRepoContents(
repoPath,
contentPath,
ref
Expand All @@ -190,7 +159,11 @@ export async function downloadGithubRepoContents(
const downloadContent = async (
contentPiece: GithubRepoFile
): Promise<void> => {
const { path: contentPiecePath, download_url } = contentPiece;
const {
path: contentPiecePath,
download_url,
type: contentPieceType,
} = contentPiece;
const downloadPath = path.join(
dest,
contentPiecePath.replace(contentPath, '')
Expand All @@ -206,6 +179,16 @@ export async function downloadGithubRepoContents(
downloadPath,
});

if (contentPieceType === 'dir') {
const { data: innerDirContent } = await fetchRepoContents(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this to get parity between the util here and in cli-lib. I'd appreciate a second pair of eyes on this, but I'm pretty sure we need this in order to be able to handle downloading deeply nested folders and files. Otherwise our recursion only ever goes one level deep.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh good catch. It looks like the check for 'dir' was added after this file was initially ported to local dev lib so it must have gotten skipped over here

repoPath,
contentPiecePath,
ref
);
await Promise.all(innerDirContent.map(downloadContent));
return Promise.resolve();
}

return fetchGitHubRepoContentFromDownloadUrl(downloadPath, download_url);
};

Expand All @@ -217,7 +200,7 @@ export async function downloadGithubRepoContents(
contentPromises = [downloadContent(contentsResp)];
}

Promise.all(contentPromises);
await Promise.all(contentPromises);
} catch (e) {
const error = e as BaseError;
if (error?.error?.message) {
Expand Down
Loading