fetch-json.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. // fetch wrapper to make simple JSON requests:
  2. // - send the CSRF token in the request
  3. // - set the JSON content-type in the request headers
  4. // - throw errors on non-ok response
  5. // - parse JSON response body, unless response is empty
  6. import OError from '@overleaf/o-error'
  7. /**
  8. * @typedef {Object} FetchOptions
  9. * @extends RequestInit
  10. * @property {Object} [body]
  11. * @property {Boolean} [swallowAbortError] Set to false for throwing AbortErrors.
  12. * @property {AbortSignal} [signal] Allows aborting a request via AbortController
  13. */
  14. /**
  15. * @param {string} path
  16. * @param {FetchOptions} [options]
  17. */
  18. export function getJSON(path, options) {
  19. return fetchJSON(path, { ...options, method: 'GET' })
  20. }
  21. /**
  22. * @param {string} path
  23. * @param {FetchOptions} [options]
  24. */
  25. export function postJSON(path, options) {
  26. return fetchJSON(path, { ...options, method: 'POST' })
  27. }
  28. /**
  29. * @param {string} path
  30. * @param {FetchOptions} [options]
  31. */
  32. export function putJSON(path, options) {
  33. return fetchJSON(path, { ...options, method: 'PUT' })
  34. }
  35. /**
  36. * @param {string} path
  37. * @param {FetchOptions} [options]
  38. */
  39. export function deleteJSON(path, options) {
  40. return fetchJSON(path, { ...options, method: 'DELETE' })
  41. }
  42. /**
  43. * @param {number} statusCode
  44. * @returns {string}
  45. */
  46. function getErrorMessageForStatusCode(statusCode) {
  47. switch (statusCode) {
  48. case 400:
  49. return 'Bad Request'
  50. case 401:
  51. return 'Unauthorized'
  52. case 403:
  53. return 'Forbidden'
  54. case 404:
  55. return 'Not Found'
  56. case 429:
  57. return 'Too Many Requests'
  58. case 500:
  59. return 'Internal Server Error'
  60. case 502:
  61. return 'Bad Gateway'
  62. case 503:
  63. return 'Service Unavailable'
  64. default:
  65. return `Unexpected Error: ${statusCode}`
  66. }
  67. }
  68. export class FetchError extends OError {
  69. /**
  70. * @param {string} message
  71. * @param {string} url
  72. * @param {FetchOptions} [options]
  73. * @param {Response} [response]
  74. * @param {Object} [data]
  75. */
  76. constructor(message, url, options, response, data) {
  77. // On HTTP2, the `statusText` property is not set,
  78. // so this `message` will be undefined. We need to
  79. // set a message based on the response `status`, so
  80. // our error UI rendering will work
  81. if (!message) {
  82. message = getErrorMessageForStatusCode(response.status)
  83. }
  84. super(message, { statusCode: response ? response.status : undefined })
  85. this.url = url
  86. this.options = options
  87. this.response = response
  88. this.data = data
  89. }
  90. /**
  91. * @returns {string}
  92. */
  93. getUserFacingMessage() {
  94. const statusCode = this.response?.status
  95. const defaultMessage = getErrorMessageForStatusCode(statusCode)
  96. const message = this.data?.message?.text || this.data?.message
  97. if (message && message !== defaultMessage) return message
  98. switch (statusCode) {
  99. case 400:
  100. return 'Invalid Request. Please correct the data and try again.'
  101. case 403:
  102. return 'Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.'
  103. case 429:
  104. return 'Too many attempts. Please wait for a while and try again.'
  105. default:
  106. return 'Something went wrong talking to the server :(. Please try again.'
  107. }
  108. }
  109. }
  110. /**
  111. * @param {string} path
  112. * @param {FetchOptions} [options]
  113. *
  114. * @return Promise<Object>
  115. */
  116. function fetchJSON(
  117. path,
  118. {
  119. body = {},
  120. headers = {},
  121. method = 'GET',
  122. credentials = 'same-origin',
  123. swallowAbortError = true,
  124. ...otherOptions
  125. }
  126. ) {
  127. const options = {
  128. ...otherOptions,
  129. headers: {
  130. ...headers,
  131. 'Content-Type': 'application/json',
  132. 'X-Csrf-Token': window.csrfToken,
  133. Accept: 'application/json',
  134. },
  135. credentials,
  136. method,
  137. }
  138. if (method !== 'GET' && method !== 'HEAD') {
  139. options.body = JSON.stringify(body)
  140. }
  141. // The returned Promise and the `.then(handleSuccess, handleError)` handlers are needed
  142. // to avoid calling `finally` in a Promise chain (and thus updating the component's state)
  143. // after a component has unmounted.
  144. // `resolve` will be called when the request succeeds, `reject` will be called when the request fails,
  145. // but nothing will be called if the request is cancelled via an AbortController.
  146. return new Promise((resolve, reject) => {
  147. fetch(path, options).then(
  148. response => {
  149. return parseResponseBody(response).then(
  150. data => {
  151. if (response.ok) {
  152. resolve(data)
  153. } else {
  154. // the response from the server was not 2xx
  155. reject(
  156. new FetchError(
  157. response.statusText,
  158. path,
  159. options,
  160. response,
  161. data
  162. )
  163. )
  164. }
  165. },
  166. error => {
  167. // parsing the response body failed
  168. reject(
  169. new FetchError(
  170. 'There was an error parsing the response body',
  171. path,
  172. options,
  173. response
  174. ).withCause(error)
  175. )
  176. }
  177. )
  178. },
  179. error => {
  180. // swallow the error if the fetch was cancelled (e.g. by cancelling an AbortController on component unmount)
  181. if (swallowAbortError && error.name === 'AbortError') {
  182. return
  183. }
  184. // the fetch failed
  185. reject(
  186. new FetchError(
  187. 'There was an error fetching the JSON',
  188. path,
  189. options
  190. ).withCause(error)
  191. )
  192. }
  193. )
  194. })
  195. }
  196. /**
  197. * @param {Response} response
  198. * @returns {Promise<Object>}
  199. */
  200. async function parseResponseBody(response) {
  201. const contentType = response.headers.get('Content-Type')
  202. if (/application\/json/.test(contentType)) {
  203. return response.json()
  204. }
  205. if (/text\/plain/.test(contentType)) {
  206. const message = await response.text()
  207. return { message }
  208. }
  209. if (/text\/html/.test(contentType)) {
  210. const message = await response.text()
  211. // only use HTML responses which don't start with `<`
  212. if (!/^\s*</.test(message)) {
  213. return { message }
  214. }
  215. }
  216. // response body ignored as content-type is either not set (e.g. 204
  217. // responses) or unsupported
  218. return {}
  219. }