fetch-json.ts 6.7 KB

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