index.js 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  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 return a string.
  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 failure status code
  79. */
  80. async function fetchString(url, opts = {}) {
  81. const { body } = await fetchStringWithResponse(url, opts)
  82. return body
  83. }
  84. async function fetchStringWithResponse(url, opts = {}) {
  85. const { fetchOpts } = parseOpts(opts)
  86. const response = await performRequest(url, fetchOpts)
  87. if (!response.ok) {
  88. const body = await maybeGetResponseBody(response)
  89. throw new RequestFailedError(url, opts, response, body)
  90. }
  91. const body = await response.text()
  92. return { body, response }
  93. }
  94. class RequestFailedError extends OError {
  95. constructor(url, opts, response, body) {
  96. super('request failed', {
  97. url,
  98. method: opts.method ?? 'GET',
  99. status: response.status,
  100. })
  101. this.response = response
  102. if (body != null) {
  103. this.body = body
  104. }
  105. }
  106. }
  107. function parseOpts(opts) {
  108. const fetchOpts = _.omit(opts, ['json', 'signal', 'basicAuth'])
  109. if (opts.json) {
  110. setupJsonBody(fetchOpts, opts.json)
  111. }
  112. if (opts.basicAuth) {
  113. setupBasicAuth(fetchOpts, opts.basicAuth)
  114. }
  115. const abortController = new AbortController()
  116. fetchOpts.signal = abortController.signal
  117. if (opts.signal) {
  118. abortOnSignal(abortController, opts.signal)
  119. }
  120. if (opts.body instanceof Readable) {
  121. abortOnDestroyedRequest(abortController, fetchOpts.body)
  122. }
  123. return { fetchOpts, abortController }
  124. }
  125. function setupJsonBody(fetchOpts, json) {
  126. fetchOpts.body = JSON.stringify(json)
  127. fetchOpts.headers = fetchOpts.headers ?? {}
  128. fetchOpts.headers['Content-Type'] = 'application/json'
  129. }
  130. function setupBasicAuth(fetchOpts, basicAuth) {
  131. fetchOpts.headers = fetchOpts.headers ?? {}
  132. fetchOpts.headers.Authorization =
  133. 'Basic ' +
  134. Buffer.from(`${basicAuth.user}:${basicAuth.password}`).toString('base64')
  135. }
  136. function abortOnSignal(abortController, signal) {
  137. const listener = () => {
  138. abortController.abort(signal.reason)
  139. }
  140. if (signal.aborted) {
  141. abortController.abort(signal.reason)
  142. }
  143. signal.addEventListener('abort', listener)
  144. }
  145. function abortOnDestroyedRequest(abortController, stream) {
  146. stream.on('close', () => {
  147. if (!stream.readableEnded) {
  148. abortController.abort()
  149. }
  150. })
  151. }
  152. function abortOnDestroyedResponse(abortController, response) {
  153. response.body.on('close', () => {
  154. if (!response.bodyUsed) {
  155. abortController.abort()
  156. }
  157. })
  158. }
  159. async function performRequest(url, fetchOpts) {
  160. let response
  161. try {
  162. response = await fetch(url, fetchOpts)
  163. } catch (err) {
  164. if (fetchOpts.body instanceof Readable) {
  165. fetchOpts.body.destroy()
  166. }
  167. throw OError.tag(err, err.message, {
  168. url,
  169. method: fetchOpts.method ?? 'GET',
  170. })
  171. }
  172. if (fetchOpts.body instanceof Readable) {
  173. response.body.on('close', () => {
  174. if (!fetchOpts.body.readableEnded) {
  175. fetchOpts.body.destroy()
  176. }
  177. })
  178. }
  179. return response
  180. }
  181. async function discardResponseBody(response) {
  182. // eslint-disable-next-line no-unused-vars
  183. for await (const chunk of response.body) {
  184. // discard the body
  185. }
  186. }
  187. async function maybeGetResponseBody(response) {
  188. try {
  189. return await response.text()
  190. } catch (err) {
  191. return null
  192. }
  193. }
  194. module.exports = {
  195. fetchJson,
  196. fetchJsonWithResponse,
  197. fetchStream,
  198. fetchStreamWithResponse,
  199. fetchNothing,
  200. fetchString,
  201. fetchStringWithResponse,
  202. RequestFailedError,
  203. }