2020-11-18 07:39:44 -05:00
|
|
|
// fetch wrapper to make simple JSON requests:
|
|
|
|
// - send the CSRF token in the request
|
|
|
|
// - set the JSON content-type in the request headers
|
|
|
|
// - throw errors on non-ok response
|
|
|
|
// - parse JSON response body, unless response is empty
|
2021-02-10 04:36:54 -05:00
|
|
|
import OError from '@overleaf/o-error'
|
2020-11-18 07:39:44 -05:00
|
|
|
|
2021-02-10 04:36:54 -05:00
|
|
|
/**
|
|
|
|
* @typedef {Object} FetchOptions
|
|
|
|
* @extends RequestInit
|
2022-04-08 07:02:17 -04:00
|
|
|
* @property {Object} [body]
|
|
|
|
* @property {Boolean} [swallowAbortError] Set to false for throwing AbortErrors.
|
|
|
|
* @property {AbortSignal} [signal] Allows aborting a request via AbortController
|
2021-02-10 04:36:54 -05:00
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {string} path
|
|
|
|
* @param {FetchOptions} [options]
|
|
|
|
*/
|
2020-11-18 07:39:44 -05:00
|
|
|
export function getJSON(path, options) {
|
|
|
|
return fetchJSON(path, { ...options, method: 'GET' })
|
|
|
|
}
|
|
|
|
|
2021-02-10 04:36:54 -05:00
|
|
|
/**
|
|
|
|
* @param {string} path
|
|
|
|
* @param {FetchOptions} [options]
|
|
|
|
*/
|
2020-11-18 07:39:44 -05:00
|
|
|
export function postJSON(path, options) {
|
|
|
|
return fetchJSON(path, { ...options, method: 'POST' })
|
|
|
|
}
|
|
|
|
|
2021-02-10 04:36:54 -05:00
|
|
|
/**
|
|
|
|
* @param {string} path
|
|
|
|
* @param {FetchOptions} [options]
|
|
|
|
*/
|
2021-01-27 05:07:26 -05:00
|
|
|
export function putJSON(path, options) {
|
|
|
|
return fetchJSON(path, { ...options, method: 'PUT' })
|
|
|
|
}
|
|
|
|
|
2021-02-10 04:36:54 -05:00
|
|
|
/**
|
|
|
|
* @param {string} path
|
|
|
|
* @param {FetchOptions} [options]
|
|
|
|
*/
|
2020-11-18 07:39:44 -05:00
|
|
|
export function deleteJSON(path, options) {
|
|
|
|
return fetchJSON(path, { ...options, method: 'DELETE' })
|
|
|
|
}
|
|
|
|
|
2021-08-24 04:54:30 -04:00
|
|
|
/**
|
|
|
|
* @param {number} statusCode
|
|
|
|
* @returns {string}
|
|
|
|
*/
|
|
|
|
function getErrorMessageForStatusCode(statusCode) {
|
|
|
|
switch (statusCode) {
|
|
|
|
case 400:
|
|
|
|
return 'Bad Request'
|
|
|
|
case 401:
|
|
|
|
return 'Unauthorized'
|
|
|
|
case 403:
|
|
|
|
return 'Forbidden'
|
|
|
|
case 404:
|
|
|
|
return 'Not Found'
|
2021-09-08 05:26:18 -04:00
|
|
|
case 429:
|
|
|
|
return 'Too Many Requests'
|
2021-08-24 04:54:30 -04:00
|
|
|
case 500:
|
|
|
|
return 'Internal Server Error'
|
|
|
|
case 502:
|
|
|
|
return 'Bad Gateway'
|
|
|
|
case 503:
|
|
|
|
return 'Service Unavailable'
|
|
|
|
default:
|
|
|
|
return `Unexpected Error: ${statusCode}`
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-02-10 04:36:54 -05:00
|
|
|
export class FetchError extends OError {
|
|
|
|
/**
|
|
|
|
* @param {string} message
|
|
|
|
* @param {string} url
|
|
|
|
* @param {FetchOptions} [options]
|
|
|
|
* @param {Response} [response]
|
|
|
|
* @param {Object} [data]
|
|
|
|
*/
|
|
|
|
constructor(message, url, options, response, data) {
|
2021-08-24 04:54:30 -04:00
|
|
|
// On HTTP2, the `statusText` property is not set,
|
|
|
|
// so this `message` will be undefined. We need to
|
|
|
|
// set a message based on the response `status`, so
|
|
|
|
// our error UI rendering will work
|
|
|
|
if (!message) {
|
|
|
|
message = getErrorMessageForStatusCode(response.status)
|
|
|
|
}
|
2021-02-10 04:36:54 -05:00
|
|
|
super(message, { statusCode: response ? response.status : undefined })
|
|
|
|
this.url = url
|
|
|
|
this.options = options
|
|
|
|
this.response = response
|
|
|
|
this.data = data
|
|
|
|
}
|
2021-09-08 05:26:18 -04:00
|
|
|
|
|
|
|
/**
|
|
|
|
* @returns {string}
|
|
|
|
*/
|
|
|
|
getUserFacingMessage() {
|
|
|
|
const statusCode = this.response?.status
|
|
|
|
const defaultMessage = getErrorMessageForStatusCode(statusCode)
|
|
|
|
const message = this.data?.message?.text || this.data?.message
|
|
|
|
if (message && message !== defaultMessage) return message
|
|
|
|
|
|
|
|
switch (statusCode) {
|
|
|
|
case 400:
|
|
|
|
return 'Invalid Request. Please correct the data and try again.'
|
|
|
|
case 403:
|
|
|
|
return 'Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.'
|
|
|
|
case 429:
|
|
|
|
return 'Too many attempts. Please wait for a while and try again.'
|
|
|
|
default:
|
2022-05-16 04:02:50 -04:00
|
|
|
return 'Something went wrong. Please try again.'
|
2021-09-08 05:26:18 -04:00
|
|
|
}
|
|
|
|
}
|
2021-02-10 04:36:54 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {string} path
|
|
|
|
* @param {FetchOptions} [options]
|
|
|
|
*
|
|
|
|
* @return Promise<Object>
|
|
|
|
*/
|
2021-01-27 05:07:36 -05:00
|
|
|
function fetchJSON(
|
2020-11-18 07:39:44 -05:00
|
|
|
path,
|
2021-01-27 05:07:57 -05:00
|
|
|
{
|
|
|
|
body = {},
|
|
|
|
headers = {},
|
|
|
|
method = 'GET',
|
|
|
|
credentials = 'same-origin',
|
2022-01-26 06:15:19 -05:00
|
|
|
swallowAbortError = true,
|
2021-01-27 05:07:57 -05:00
|
|
|
...otherOptions
|
|
|
|
}
|
2020-11-18 07:39:44 -05:00
|
|
|
) {
|
|
|
|
const options = {
|
|
|
|
...otherOptions,
|
|
|
|
headers: {
|
|
|
|
...headers,
|
|
|
|
'Content-Type': 'application/json',
|
2021-01-05 05:57:18 -05:00
|
|
|
'X-Csrf-Token': window.csrfToken,
|
2021-04-27 03:52:58 -04:00
|
|
|
Accept: 'application/json',
|
2020-11-18 07:39:44 -05:00
|
|
|
},
|
2021-01-27 05:07:57 -05:00
|
|
|
credentials,
|
2021-04-27 03:52:58 -04:00
|
|
|
method,
|
2020-11-18 07:39:44 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
|
|
options.body = JSON.stringify(body)
|
|
|
|
}
|
|
|
|
|
2021-06-23 04:09:23 -04:00
|
|
|
// The returned Promise and the `.then(handleSuccess, handleError)` handlers are needed
|
|
|
|
// to avoid calling `finally` in a Promise chain (and thus updating the component's state)
|
|
|
|
// after a component has unmounted.
|
|
|
|
// `resolve` will be called when the request succeeds, `reject` will be called when the request fails,
|
|
|
|
// but nothing will be called if the request is cancelled via an AbortController.
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
fetch(path, options).then(
|
|
|
|
response => {
|
|
|
|
return parseResponseBody(response).then(
|
|
|
|
data => {
|
|
|
|
if (response.ok) {
|
|
|
|
resolve(data)
|
|
|
|
} else {
|
|
|
|
// the response from the server was not 2xx
|
|
|
|
reject(
|
|
|
|
new FetchError(
|
|
|
|
response.statusText,
|
|
|
|
path,
|
|
|
|
options,
|
|
|
|
response,
|
|
|
|
data
|
|
|
|
)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
},
|
|
|
|
error => {
|
|
|
|
// parsing the response body failed
|
|
|
|
reject(
|
|
|
|
new FetchError(
|
|
|
|
'There was an error parsing the response body',
|
|
|
|
path,
|
|
|
|
options,
|
|
|
|
response
|
|
|
|
).withCause(error)
|
2021-02-10 04:36:54 -05:00
|
|
|
)
|
|
|
|
}
|
2021-06-23 04:09:23 -04:00
|
|
|
)
|
|
|
|
},
|
|
|
|
error => {
|
|
|
|
// swallow the error if the fetch was cancelled (e.g. by cancelling an AbortController on component unmount)
|
2022-01-26 06:15:19 -05:00
|
|
|
if (swallowAbortError && error.name === 'AbortError') {
|
|
|
|
return
|
2021-06-23 04:09:23 -04:00
|
|
|
}
|
2022-01-26 06:15:19 -05:00
|
|
|
// the fetch failed
|
|
|
|
reject(
|
|
|
|
new FetchError(
|
|
|
|
'There was an error fetching the JSON',
|
|
|
|
path,
|
|
|
|
options
|
|
|
|
).withCause(error)
|
|
|
|
)
|
2021-06-23 04:09:23 -04:00
|
|
|
}
|
|
|
|
)
|
|
|
|
})
|
2021-01-05 05:57:18 -05:00
|
|
|
}
|
|
|
|
|
2021-02-10 04:36:54 -05:00
|
|
|
/**
|
|
|
|
* @param {Response} response
|
|
|
|
* @returns {Promise<Object>}
|
|
|
|
*/
|
2021-06-24 04:28:23 -04:00
|
|
|
async function parseResponseBody(response) {
|
2021-01-05 05:57:18 -05:00
|
|
|
const contentType = response.headers.get('Content-Type')
|
2021-06-24 04:28:23 -04:00
|
|
|
|
2021-01-05 05:57:18 -05:00
|
|
|
if (/application\/json/.test(contentType)) {
|
2021-02-10 04:36:54 -05:00
|
|
|
return response.json()
|
2021-01-05 05:57:18 -05:00
|
|
|
}
|
2021-06-24 04:28:23 -04:00
|
|
|
|
|
|
|
if (/text\/plain/.test(contentType)) {
|
|
|
|
const message = await response.text()
|
|
|
|
|
|
|
|
return { message }
|
|
|
|
}
|
|
|
|
|
|
|
|
if (/text\/html/.test(contentType)) {
|
|
|
|
const message = await response.text()
|
|
|
|
|
|
|
|
// only use HTML responses which don't start with `<`
|
|
|
|
if (!/^\s*</.test(message)) {
|
|
|
|
return { message }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// response body ignored as content-type is either not set (e.g. 204
|
|
|
|
// responses) or unsupported
|
|
|
|
return {}
|
2020-11-18 07:39:44 -05:00
|
|
|
}
|