fetch-json.js 6.1 KB

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