index.js 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. const _ = require('lodash')
  2. const { Readable } = require('stream')
  3. const OError = require('@overleaf/o-error')
  4. const fetch = require('node-fetch')
  5. /**
  6. * Make a request and return the parsed JSON response.
  7. *
  8. * @param {string | URL} url - request URL
  9. * @param {object} opts - fetch options
  10. * @return {Promise<object>} the parsed JSON response
  11. * @throws {RequestFailedError} if the response has a failure status code
  12. */
  13. async function fetchJson(url, opts = {}) {
  14. const { json } = await fetchJsonWithResponse(url, opts)
  15. return json
  16. }
  17. async function fetchJsonWithResponse(url, opts = {}) {
  18. const { fetchOpts } = parseOpts(opts)
  19. fetchOpts.headers = fetchOpts.headers ?? {}
  20. fetchOpts.headers.Accept = 'application/json'
  21. const response = await performRequest(url, fetchOpts)
  22. if (!response.ok) {
  23. const body = await maybeGetResponseBody(response)
  24. throw new RequestFailedError(url, opts, response, body)
  25. }
  26. const json = await response.json()
  27. return { json, response }
  28. }
  29. /**
  30. * Make a request and return a stream.
  31. *
  32. * If the response body is destroyed, the request is aborted.
  33. *
  34. * @param {string | URL} url - request URL
  35. * @param {object} opts - fetch options
  36. * @return {Promise<Readable>}
  37. * @throws {RequestFailedError} if the response has a failure status code
  38. */
  39. async function fetchStream(url, opts = {}) {
  40. const { stream } = await fetchStreamWithResponse(url, opts)
  41. return stream
  42. }
  43. async function fetchStreamWithResponse(url, opts = {}) {
  44. const { fetchOpts, abortController } = parseOpts(opts)
  45. const response = await performRequest(url, fetchOpts)
  46. if (!response.ok) {
  47. const body = await maybeGetResponseBody(response)
  48. throw new RequestFailedError(url, opts, response, body)
  49. }
  50. abortOnDestroyedResponse(abortController, response)
  51. const stream = response.body
  52. return { stream, response }
  53. }
  54. /**
  55. * Make a request and discard the response.
  56. *
  57. * @param {string | URL} url - request URL
  58. * @param {object} opts - fetch options
  59. * @return {Promise<Response>}
  60. * @throws {RequestFailedError} if the response has a failure status code
  61. */
  62. async function fetchNothing(url, opts = {}) {
  63. const { fetchOpts } = parseOpts(opts)
  64. const response = await performRequest(url, fetchOpts)
  65. if (!response.ok) {
  66. const body = await maybeGetResponseBody(response)
  67. throw new RequestFailedError(url, opts, response, body)
  68. }
  69. await discardResponseBody(response)
  70. return response
  71. }
  72. /**
  73. * Make a request and extract the redirect from the response.
  74. *
  75. * @param {string | URL} url - request URL
  76. * @param {object} opts - fetch options
  77. * @return {Promise<string>}
  78. * @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
  79. */
  80. async function fetchRedirect(url, opts = {}) {
  81. const { fetchOpts } = parseOpts(opts)
  82. fetchOpts.redirect = 'manual'
  83. const response = await performRequest(url, fetchOpts)
  84. if (response.status < 300 || response.status >= 400) {
  85. const body = await maybeGetResponseBody(response)
  86. throw new RequestFailedError(url, opts, response, body)
  87. }
  88. const location = response.headers.get('Location')
  89. if (!location) {
  90. const body = await maybeGetResponseBody(response)
  91. throw new RequestFailedError(url, opts, response, body).withCause(
  92. new OError('missing Location response header on 3xx response', {
  93. headers: Object.fromEntries(response.headers.entries()),
  94. })
  95. )
  96. }
  97. await discardResponseBody(response)
  98. return location
  99. }
  100. /**
  101. * Make a request and return a string.
  102. *
  103. * @param {string | URL} url - request URL
  104. * @param {object} opts - fetch options
  105. * @return {Promise<string>}
  106. * @throws {RequestFailedError} if the response has a failure status code
  107. */
  108. async function fetchString(url, opts = {}) {
  109. const { body } = await fetchStringWithResponse(url, opts)
  110. return body
  111. }
  112. async function fetchStringWithResponse(url, opts = {}) {
  113. const { fetchOpts } = parseOpts(opts)
  114. const response = await performRequest(url, fetchOpts)
  115. if (!response.ok) {
  116. const body = await maybeGetResponseBody(response)
  117. throw new RequestFailedError(url, opts, response, body)
  118. }
  119. const body = await response.text()
  120. return { body, response }
  121. }
  122. class RequestFailedError extends OError {
  123. constructor(url, opts, response, body) {
  124. super('request failed', {
  125. url,
  126. method: opts.method ?? 'GET',
  127. status: response.status,
  128. })
  129. this.response = response
  130. if (body != null) {
  131. this.body = body
  132. }
  133. }
  134. }
  135. function parseOpts(opts) {
  136. const fetchOpts = _.omit(opts, ['json', 'signal', 'basicAuth'])
  137. if (opts.json) {
  138. setupJsonBody(fetchOpts, opts.json)
  139. }
  140. if (opts.basicAuth) {
  141. setupBasicAuth(fetchOpts, opts.basicAuth)
  142. }
  143. const abortController = new AbortController()
  144. fetchOpts.signal = abortController.signal
  145. if (opts.signal) {
  146. abortOnSignal(abortController, opts.signal)
  147. }
  148. if (opts.body instanceof Readable) {
  149. abortOnDestroyedRequest(abortController, fetchOpts.body)
  150. }
  151. return { fetchOpts, abortController }
  152. }
  153. function setupJsonBody(fetchOpts, json) {
  154. fetchOpts.body = JSON.stringify(json)
  155. fetchOpts.headers = fetchOpts.headers ?? {}
  156. fetchOpts.headers['Content-Type'] = 'application/json'
  157. }
  158. function setupBasicAuth(fetchOpts, basicAuth) {
  159. fetchOpts.headers = fetchOpts.headers ?? {}
  160. fetchOpts.headers.Authorization =
  161. 'Basic ' +
  162. Buffer.from(`${basicAuth.user}:${basicAuth.password}`).toString('base64')
  163. }
  164. function abortOnSignal(abortController, signal) {
  165. const listener = () => {
  166. abortController.abort(signal.reason)
  167. }
  168. if (signal.aborted) {
  169. abortController.abort(signal.reason)
  170. }
  171. signal.addEventListener('abort', listener)
  172. }
  173. function abortOnDestroyedRequest(abortController, stream) {
  174. stream.on('close', () => {
  175. if (!stream.readableEnded) {
  176. abortController.abort()
  177. }
  178. })
  179. }
  180. function abortOnDestroyedResponse(abortController, response) {
  181. response.body.on('close', () => {
  182. if (!response.bodyUsed) {
  183. abortController.abort()
  184. }
  185. })
  186. }
  187. async function performRequest(url, fetchOpts) {
  188. let response
  189. try {
  190. response = await fetch(url, fetchOpts)
  191. } catch (err) {
  192. if (fetchOpts.body instanceof Readable) {
  193. fetchOpts.body.destroy()
  194. }
  195. throw OError.tag(err, err.message, {
  196. url,
  197. method: fetchOpts.method ?? 'GET',
  198. })
  199. }
  200. if (fetchOpts.body instanceof Readable) {
  201. response.body.on('close', () => {
  202. if (!fetchOpts.body.readableEnded) {
  203. fetchOpts.body.destroy()
  204. }
  205. })
  206. }
  207. return response
  208. }
  209. async function discardResponseBody(response) {
  210. // eslint-disable-next-line no-unused-vars
  211. for await (const chunk of response.body) {
  212. // discard the body
  213. }
  214. }
  215. async function maybeGetResponseBody(response) {
  216. try {
  217. return await response.text()
  218. } catch (err) {
  219. return null
  220. }
  221. }
  222. module.exports = {
  223. fetchJson,
  224. fetchJsonWithResponse,
  225. fetchStream,
  226. fetchStreamWithResponse,
  227. fetchNothing,
  228. fetchRedirect,
  229. fetchString,
  230. fetchStringWithResponse,
  231. RequestFailedError,
  232. }