fetch-json.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  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. public url: string
  43. public options?: RequestInit
  44. public response?: Response
  45. public data?: any
  46. constructor(
  47. message: string,
  48. url: string,
  49. options?: RequestInit,
  50. response?: Response,
  51. data?: any
  52. ) {
  53. // On HTTP2, the `statusText` property is not set,
  54. // so this `message` will be undefined. We need to
  55. // set a message based on the response `status`, so
  56. // our error UI rendering will work
  57. if (!message) {
  58. message = getErrorMessageForStatusCode(response?.status)
  59. }
  60. super(message, { statusCode: response ? response.status : undefined })
  61. this.url = url
  62. this.options = options
  63. this.response = response
  64. this.data = data
  65. }
  66. getErrorMessageKey() {
  67. return this.data?.message?.key as string | undefined
  68. }
  69. getUserFacingMessage() {
  70. const statusCode = this.response?.status
  71. const defaultMessage = getErrorMessageForStatusCode(statusCode)
  72. const message = (this.data?.message?.text || this.data?.message) as
  73. | string
  74. | undefined
  75. if (message && message !== defaultMessage) return message
  76. const statusCodes: { readonly [K: number]: string } = {
  77. 400: 'Invalid Request. Please correct the data and try again.',
  78. 403: 'Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.',
  79. 429: 'Too many attempts. Please wait for a while and try again.',
  80. }
  81. return statusCode && statusCodes[statusCode]
  82. ? statusCodes[statusCode]
  83. : 'Something went wrong. Please try again.'
  84. }
  85. }
  86. function fetchJSON<T>(
  87. path: FetchPath,
  88. {
  89. body = {},
  90. headers = {},
  91. method = 'GET',
  92. credentials = 'same-origin',
  93. swallowAbortError = true,
  94. ...otherOptions
  95. }: FetchConfig
  96. ) {
  97. const options: RequestInit = {
  98. ...otherOptions,
  99. headers: {
  100. ...headers,
  101. 'Content-Type': 'application/json',
  102. 'X-Csrf-Token': window.csrfToken,
  103. Accept: 'application/json',
  104. },
  105. credentials,
  106. method,
  107. }
  108. if (method !== 'GET' && method !== 'HEAD') {
  109. options.body = JSON.stringify(body)
  110. }
  111. // The returned Promise and the `.then(handleSuccess, handleError)` handlers are needed
  112. // to avoid calling `finally` in a Promise chain (and thus updating the component's state)
  113. // after a component has unmounted.
  114. // `resolve` will be called when the request succeeds, `reject` will be called when the request fails,
  115. // but nothing will be called if the request is cancelled via an AbortController.
  116. return new Promise<T>((resolve, reject) => {
  117. fetch(path, options).then(
  118. response => {
  119. return parseResponseBody(response).then(
  120. data => {
  121. if (response.ok) {
  122. resolve(data)
  123. } else {
  124. // the response from the server was not 2xx
  125. reject(
  126. new FetchError(
  127. response.statusText,
  128. path,
  129. options,
  130. response,
  131. data
  132. )
  133. )
  134. }
  135. },
  136. error => {
  137. // parsing the response body failed
  138. reject(
  139. new FetchError(
  140. 'There was an error parsing the response body',
  141. path,
  142. options,
  143. response
  144. ).withCause(error)
  145. )
  146. }
  147. )
  148. },
  149. error => {
  150. // swallow the error if the fetch was cancelled (e.g. by cancelling an AbortController on component unmount)
  151. if (swallowAbortError && error.name === 'AbortError') {
  152. return
  153. }
  154. // the fetch failed
  155. reject(
  156. new FetchError(
  157. 'There was an error fetching the JSON',
  158. path,
  159. options
  160. ).withCause(error)
  161. )
  162. }
  163. )
  164. })
  165. }
  166. async function parseResponseBody(response: Response) {
  167. const contentType = response.headers.get('Content-Type')
  168. if (!contentType) {
  169. return {}
  170. }
  171. if (/application\/json/.test(contentType)) {
  172. return response.json()
  173. }
  174. if (/text\/plain/.test(contentType)) {
  175. const message = await response.text()
  176. return { message }
  177. }
  178. if (/text\/html/.test(contentType)) {
  179. const message = await response.text()
  180. // only use HTML responses which don't start with `<`
  181. if (!/^\s*</.test(message)) {
  182. return { message }
  183. }
  184. }
  185. // response body ignored as content-type is either not set (e.g. 204
  186. // responses) or unsupported
  187. return {}
  188. }
  189. export function getErrorMessageKey(error: Error | null) {
  190. if (!error) {
  191. return undefined
  192. }
  193. if (error instanceof FetchError) {
  194. return error.getErrorMessageKey()
  195. }
  196. return error.message
  197. }
  198. export function getUserFacingMessage(error: Error | null) {
  199. if (!error) {
  200. return undefined
  201. }
  202. if (error instanceof FetchError) {
  203. return error.getUserFacingMessage()
  204. }
  205. return error.message
  206. }