fetch-json.js 5.9 KB

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