fetch-json.ts 5.6 KB

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