fetch-json.ts 6.9 KB

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