summaryrefslogtreecommitdiff
path: root/source/github-helpers/api.ts
diff options
context:
space:
mode:
authorGravatar Fregante <opensource@bfred.it> 2020-05-19 22:52:08 +0200
committerGravatar GitHub <noreply@github.com> 2020-05-19 22:52:08 +0200
commitf9d8b7a5a402e19b0cb16fd937e0f19c43a6e699 (patch)
tree0b7a3d6cc31c8975921a7d7fcf6c4bc7e0e020e2 /source/github-helpers/api.ts
parent508c93a3f4cf219f343d19b927ae0d07ec3ad7f8 (diff)
downloadrefined-github-f9d8b7a5a402e19b0cb16fd937e0f19c43a6e699.tar.gz
refined-github-f9d8b7a5a402e19b0cb16fd937e0f19c43a6e699.tar.zst
refined-github-f9d8b7a5a402e19b0cb16fd937e0f19c43a6e699.zip
Meta: Reorganize utility functions (#3110)
Diffstat (limited to 'source/github-helpers/api.ts')
-rw-r--r--source/github-helpers/api.ts210
1 files changed, 210 insertions, 0 deletions
diff --git a/source/github-helpers/api.ts b/source/github-helpers/api.ts
new file mode 100644
index 00000000..98849238
--- /dev/null
+++ b/source/github-helpers/api.ts
@@ -0,0 +1,210 @@
+/*
+These will throw `RefinedGitHubAPIError` if something goes wrong or if it's a 404.
+Probably don't catch them so they will appear in the console
+next to the name of the feature that caused them.
+
+Usage:
+
+import * as api from '../github-helpers/api';
+const user = await api.v3(`users/${username}`);
+const data = await api.v4('{user(login: "user") {name}}');
+
+Returns:
+a Promise that resolves into an object.
+
+If the response body is empty, you'll receive an object like {status: 200}
+
+The second argument is an options object,
+it lets you define accept error HTTP codes as a valid response, like:
+
+{
+ ignoreHTTPStatus: true
+}
+
+so the call will not throw an error but it will return as usual.
+ */
+
+import mem from 'mem';
+import {JsonObject, AsyncReturnType} from 'type-fest';
+
+import optionsStorage from '../options-storage';
+
+type JsonError = {
+ message: string;
+};
+
+interface GraphQLResponse {
+ message?: string;
+ data?: JsonObject;
+ errors?: JsonError[];
+}
+
+interface RestResponse extends AnyObject {
+ httpStatus: number;
+ headers: Headers;
+ ok: boolean;
+}
+
+export const escapeKey = (value: string | number): string => '_' + String(value).replace(/[ ./-]/g, '_');
+
+export class RefinedGitHubAPIError extends Error {
+ constructor(...messages: string[]) {
+ super(messages.join('\n'));
+ }
+}
+
+const settings = optionsStorage.getAll();
+
+const api3 = location.hostname === 'github.com' ?
+ 'https://api.github.com/' :
+ `${location.origin}/api/v3/`;
+const api4 = location.hostname === 'github.com' ?
+ 'https://api.github.com/graphql' :
+ `${location.origin}/api/graphql`;
+
+interface GHRestApiOptions {
+ ignoreHTTPStatus?: boolean;
+ method?: 'GET' | 'POST' | 'PUT';
+ body?: undefined | JsonObject;
+ headers?: HeadersInit;
+ json?: boolean;
+}
+
+interface GHGraphQLApiOptions {
+ allowErrors?: boolean;
+}
+
+const v3defaults: GHRestApiOptions = {
+ ignoreHTTPStatus: false,
+ method: 'GET',
+ body: undefined,
+ json: true
+};
+
+const v4defaults: GHGraphQLApiOptions = {
+ allowErrors: false
+};
+
+export const v3 = mem(async (
+ query: string,
+ options: GHRestApiOptions = v3defaults
+): Promise<RestResponse> => {
+ const {ignoreHTTPStatus, method, body, headers, json} = {...v3defaults, ...options};
+ const {personalToken} = await settings;
+
+ if (query.startsWith('/')) {
+ throw new TypeError('The query parameter must not start with a slash.');
+ }
+
+ const url = new URL(query, api3);
+ const response = await fetch(url.href, {
+ method,
+ body: body && JSON.stringify(body),
+ headers: {
+ 'User-Agent': 'Refined GitHub',
+ Accept: 'application/vnd.github.v3+json',
+ ...headers,
+ ...personalToken && {Authorization: `token ${personalToken}`}
+ }
+ });
+ const textContent = await response.text();
+ const apiResponse = json ? JSON.parse(textContent) : {textContent};
+
+ if (response.ok || ignoreHTTPStatus) {
+ return Object.assign(apiResponse, {
+ httpStatus: response.status,
+ headers: response.headers,
+ ok: response.ok
+ });
+ }
+
+ throw await getError(apiResponse);
+}, {
+ cacheKey: JSON.stringify
+});
+
+export const v3paginated = async function * (
+ query: string,
+ options?: GHRestApiOptions
+): AsyncGenerator<AsyncReturnType<typeof v3>> {
+ while (true) {
+ // eslint-disable-next-line no-await-in-loop
+ const response = await v3(query, options);
+ yield response;
+
+ [, query] = /<([^>]+)>; rel="next"/.exec(response.headers.get('link')!) ?? [];
+ if (!query) {
+ return;
+ }
+ }
+};
+
+export const v4 = mem(async (
+ query: string,
+ options: GHGraphQLApiOptions = v4defaults
+): Promise<AnyObject> => {
+ const {personalToken} = await settings;
+
+ if (/^(query )?{/.test(query.trimStart())) {
+ throw new TypeError('`query` should only be what’s inside \'query {...}\', like \'user(login: "foo") { name }\', but is \n' + query);
+ }
+
+ if (!personalToken) {
+ throw new Error('Personal token required for this feature');
+ }
+
+ const response = await fetch(api4, {
+ headers: {
+ 'User-Agent': 'Refined GitHub',
+ Authorization: `bearer ${personalToken}`
+ },
+ method: 'POST',
+ body: JSON.stringify({query: `{${query}}`})
+ });
+
+ const apiResponse: GraphQLResponse = await response.json();
+
+ const {
+ data = {},
+ errors = []
+ } = apiResponse;
+
+ if (errors.length > 0 && !options.allowErrors) {
+ throw new RefinedGitHubAPIError('GraphQL:', ...errors.map(error => error.message));
+ }
+
+ if (response.ok) {
+ return data;
+ }
+
+ throw await getError(apiResponse as JsonObject);
+}, {
+ cacheKey: JSON.stringify
+});
+
+export async function getError(apiResponse: JsonObject): Promise<RefinedGitHubAPIError> {
+ const {personalToken} = await settings;
+
+ if ((apiResponse.message as string)?.includes('API rate limit exceeded')) {
+ return new RefinedGitHubAPIError(
+ 'Rate limit exceeded.',
+ personalToken ?
+ 'It may be time for a walk! πŸƒ 🌞' :
+ 'Set your token in the options or take a walk! πŸƒ 🌞'
+ );
+ }
+
+ if (apiResponse.message === 'Bad credentials') {
+ return new RefinedGitHubAPIError(
+ 'The token seems to be incorrect or expired. Update it in the options.'
+ );
+ }
+
+ return new RefinedGitHubAPIError(
+ 'Unable to fetch.',
+ personalToken ?
+ 'Ensure that your token has access to this repo.' :
+ 'Maybe adding a token in the options will fix this issue.',
+ JSON.stringify(apiResponse, null, '\t') // Beautify
+ );
+}