summaryrefslogtreecommitdiff
path: root/source/libs/api.ts
blob: bf5e91497bd7f8f01113d3c575009dcc07b31193 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
/*
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 '../libs/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 OptionsSync from 'webext-options-sync';
import {JsonObject} from 'type-fest';

type JsonError = {
	message: string;
};

interface APIResponse {
	message?: string;
}

interface GraphQLResponse extends APIResponse {
	data?: JsonObject;
	errors?: JsonError[];
}

export const escapeKey = (value: string): string => '_' + value.replace(/[ ./-]/g, '_');

export class RefinedGitHubAPIError extends Error {
	constructor(...messages: string[]) {
		super(messages.join('\n'));
	}
}

const settings = new OptionsSync().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';
	body?: undefined | JsonObject;
}

const v3defaults: GHRestApiOptions = {
	ignoreHTTPStatus: false,
	method: 'GET',
	body: undefined
};

export const v3 = mem(async (
	query: string,
	options: GHRestApiOptions = v3defaults
): Promise<AnyObject> => {
	const {ignoreHTTPStatus, method, body} = {...v3defaults, ...options};
	const {personalToken} = await settings;

	const response = await fetch(api3 + query, {
		method,
		body: body && JSON.stringify(body),
		headers: {
			'User-Agent': 'Refined GitHub',
			Accept: 'application/vnd.github.v3+json',
			...(personalToken ? {Authorization: `token ${personalToken}`} : {})
		}
	});
	const textContent = await response.text();

	// The response might just be a 200 or 404, it's the REST equivalent of `boolean`
	const apiResponse: JsonObject = textContent.length > 0 ? JSON.parse(textContent) : {status: response.status};

	if (response.ok || ignoreHTTPStatus) {
		return apiResponse;
	}

	throw getError(apiResponse);
});

export const v4 = mem(async (query: string): Promise<AnyObject> => {
	const {personalToken} = await settings;

	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})
	});

	const apiResponse: GraphQLResponse = await response.json();

	const {
		data = {},
		errors = []
	} = apiResponse;

	if (errors.length > 0) {
		throw Object.assign(
			new RefinedGitHubAPIError('GraphQL:', ...errors.map(error => error.message)),
			apiResponse
		);
	}

	if (response.ok) {
		return data;
	}

	throw getError(apiResponse as JsonObject);
});

async function getError(apiResponse: JsonObject): Promise<RefinedGitHubAPIError> {
	const {personalToken} = await settings;

	if (typeof apiResponse.message === 'string' && apiResponse.message.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
	);
}