api.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. import {browserHistory} from 'react-router';
  2. import {Severity} from '@sentry/react';
  3. import jQuery from 'jquery';
  4. import Cookies from 'js-cookie';
  5. import isUndefined from 'lodash/isUndefined';
  6. import {openSudo, redirectToProject} from 'app/actionCreators/modal';
  7. import {EXPERIMENTAL_SPA} from 'app/constants';
  8. import {
  9. PROJECT_MOVED,
  10. SUDO_REQUIRED,
  11. SUPERUSER_REQUIRED,
  12. } from 'app/constants/apiErrorCodes';
  13. import {metric} from 'app/utils/analytics';
  14. import {run} from 'app/utils/apiSentryClient';
  15. import getCsrfToken from 'app/utils/getCsrfToken';
  16. import {uniqueId} from 'app/utils/guid';
  17. import createRequestError from 'app/utils/requestError/createRequestError';
  18. export class Request {
  19. /**
  20. * Is the request still in flight
  21. */
  22. alive: boolean;
  23. /**
  24. * Promise which will be resolved when the request has completed
  25. */
  26. requestPromise: Promise<Response>;
  27. /**
  28. * AbortController to cancel the in-flight request. This will not be set in
  29. * unsupported browsers.
  30. */
  31. aborter?: AbortController;
  32. constructor(requestPromise: Promise<Response>, aborter?: AbortController) {
  33. this.requestPromise = requestPromise;
  34. this.aborter = aborter;
  35. this.alive = true;
  36. }
  37. cancel() {
  38. this.alive = false;
  39. this.aborter?.abort();
  40. metric('app.api.request-abort', 1);
  41. }
  42. }
  43. /**
  44. * Check if the requested method does not require CSRF tokens
  45. */
  46. function csrfSafeMethod(method?: string) {
  47. // these HTTP methods do not require CSRF protection
  48. return /^(GET|HEAD|OPTIONS|TRACE)$/.test(method ?? '');
  49. }
  50. // TODO: Need better way of identifying anonymous pages that don't trigger redirect
  51. const ALLOWED_ANON_PAGES = [
  52. /^\/accept\//,
  53. /^\/share\//,
  54. /^\/auth\/login\//,
  55. /^\/join-request\//,
  56. ];
  57. const globalErrorHandlers: ((jqXHR: JQueryXHR) => void)[] = [];
  58. export const initApiClientErrorHandling = () =>
  59. globalErrorHandlers.push((jqXHR: JQueryXHR) => {
  60. const pageAllowsAnon = ALLOWED_ANON_PAGES.find(regex =>
  61. regex.test(window.location.pathname)
  62. );
  63. // Ignore error unless it is a 401
  64. if (!jqXHR || jqXHR.status !== 401 || pageAllowsAnon) {
  65. return;
  66. }
  67. const code = jqXHR?.responseJSON?.detail?.code;
  68. const extra = jqXHR?.responseJSON?.detail?.extra;
  69. // 401s can also mean sudo is required or it's a request that is allowed to fail
  70. // Ignore if these are the cases
  71. if (
  72. [
  73. 'sudo-required',
  74. 'ignore',
  75. '2fa-required',
  76. 'app-connect-authentication-error',
  77. 'itunes-authentication-error',
  78. 'itunes-2fa-required',
  79. ].includes(code)
  80. ) {
  81. return;
  82. }
  83. // If user must login via SSO, redirect to org login page
  84. if (code === 'sso-required') {
  85. window.location.assign(extra.loginUrl);
  86. return;
  87. }
  88. if (code === 'member-disabled-over-limit') {
  89. browserHistory.replace(extra.next);
  90. }
  91. // Otherwise, the user has become unauthenticated. Send them to auth
  92. Cookies.set('session_expired', '1');
  93. if (EXPERIMENTAL_SPA) {
  94. browserHistory.replace('/auth/login/');
  95. } else {
  96. window.location.reload();
  97. }
  98. });
  99. /**
  100. * Construct a full request URL
  101. */
  102. function buildRequestUrl(baseUrl: string, path: string, query: RequestOptions['query']) {
  103. let params: string;
  104. try {
  105. params = jQuery.param(query ?? [], true);
  106. } catch (err) {
  107. run(Sentry =>
  108. Sentry.withScope(scope => {
  109. scope.setExtra('path', path);
  110. scope.setExtra('query', query);
  111. Sentry.captureException(err);
  112. })
  113. );
  114. throw err;
  115. }
  116. let fullUrl: string;
  117. // Append the baseUrl
  118. if (path.indexOf(baseUrl) === -1) {
  119. fullUrl = baseUrl + path;
  120. } else {
  121. fullUrl = path;
  122. }
  123. if (!params) {
  124. return fullUrl;
  125. }
  126. // Append query parameters
  127. if (fullUrl.indexOf('?') !== -1) {
  128. fullUrl += '&' + params;
  129. } else {
  130. fullUrl += '?' + params;
  131. }
  132. return fullUrl;
  133. }
  134. /**
  135. * Check if the API response says project has been renamed. If so, redirect
  136. * user to new project slug
  137. */
  138. // TODO(ts): refine this type later
  139. export function hasProjectBeenRenamed(response: JQueryXHR) {
  140. const code = response?.responseJSON?.detail?.code;
  141. // XXX(billy): This actually will never happen because we can't intercept the 302
  142. // jQuery ajax will follow the redirect by default...
  143. if (code !== PROJECT_MOVED) {
  144. return false;
  145. }
  146. const slug = response?.responseJSON?.detail?.extra?.slug;
  147. redirectToProject(slug);
  148. return true;
  149. }
  150. // TODO(ts): move this somewhere
  151. export type APIRequestMethod = 'POST' | 'GET' | 'DELETE' | 'PUT';
  152. type FunctionCallback<Args extends any[] = any[]> = (...args: Args) => void;
  153. export type RequestCallbacks = {
  154. success?: (data: any, textStatus?: string, xhr?: JQueryXHR) => void;
  155. complete?: (jqXHR: JQueryXHR, textStatus: string) => void;
  156. // TODO(ts): Update this when sentry is mostly migrated to TS
  157. error?: FunctionCallback;
  158. };
  159. export type RequestOptions = RequestCallbacks & {
  160. /**
  161. * The HTTP method to use when making the API request
  162. */
  163. method?: APIRequestMethod;
  164. /**
  165. * Values to attach to the body of the request.
  166. */
  167. data?: any;
  168. /**
  169. * Query parameters to add to the requested URL.
  170. */
  171. query?: Array<any> | object;
  172. /**
  173. * Because of the async nature of API requests, errors will happen outside of
  174. * the stack that initated the request. a preservedError can be passed to
  175. * coalesce the stacks together.
  176. */
  177. preservedError?: Error;
  178. };
  179. type ClientOptions = {
  180. /**
  181. * The base URL path to prepend to API request URIs.
  182. */
  183. baseUrl?: string;
  184. };
  185. type HandleRequestErrorOptions = {
  186. id: string;
  187. path: string;
  188. requestOptions: Readonly<RequestOptions>;
  189. };
  190. /**
  191. * The API client is used to make HTTP requests to Sentry's backend.
  192. *
  193. * This is they preferred way to talk to the backend.
  194. */
  195. export class Client {
  196. baseUrl: string;
  197. activeRequests: Record<string, Request>;
  198. constructor(options: ClientOptions = {}) {
  199. this.baseUrl = options.baseUrl ?? '/api/0';
  200. this.activeRequests = {};
  201. }
  202. wrapCallback<T extends any[]>(
  203. id: string,
  204. func: FunctionCallback<T> | undefined,
  205. cleanup: boolean = false
  206. ) {
  207. return (...args: T) => {
  208. const req = this.activeRequests[id];
  209. if (cleanup === true) {
  210. delete this.activeRequests[id];
  211. }
  212. if (req && req.alive) {
  213. // Check if API response is a 302 -- means project slug was renamed and user
  214. // needs to be redirected
  215. // @ts-expect-error
  216. if (hasProjectBeenRenamed(...args)) {
  217. return;
  218. }
  219. if (isUndefined(func)) {
  220. return;
  221. }
  222. // Call success callback
  223. return func.apply(req, args); // eslint-disable-line
  224. }
  225. };
  226. }
  227. /**
  228. * Attempt to cancel all active XHR requests
  229. */
  230. clear() {
  231. Object.values(this.activeRequests).forEach(r => r.cancel());
  232. }
  233. handleRequestError(
  234. {id, path, requestOptions}: HandleRequestErrorOptions,
  235. response: JQueryXHR,
  236. textStatus: string,
  237. errorThrown: string
  238. ) {
  239. const code = response?.responseJSON?.detail?.code;
  240. const isSudoRequired = code === SUDO_REQUIRED || code === SUPERUSER_REQUIRED;
  241. let didSuccessfullyRetry = false;
  242. if (isSudoRequired) {
  243. openSudo({
  244. superuser: code === SUPERUSER_REQUIRED,
  245. sudo: code === SUDO_REQUIRED,
  246. retryRequest: async () => {
  247. try {
  248. const data = await this.requestPromise(path, requestOptions);
  249. requestOptions.success?.(data);
  250. didSuccessfullyRetry = true;
  251. } catch (err) {
  252. requestOptions.error?.(err);
  253. }
  254. },
  255. onClose: () =>
  256. // If modal was closed, then forward the original response
  257. !didSuccessfullyRetry && requestOptions.error?.(response),
  258. });
  259. return;
  260. }
  261. // Call normal error callback
  262. const errorCb = this.wrapCallback<[JQueryXHR, string, string]>(
  263. id,
  264. requestOptions.error
  265. );
  266. errorCb?.(response, textStatus, errorThrown);
  267. }
  268. /**
  269. * Initate a request to the backend API.
  270. *
  271. * Consider using `requestPromise` for the async Promise version of this method.
  272. */
  273. request(path: string, options: Readonly<RequestOptions> = {}): Request {
  274. const method = options.method || (options.data ? 'POST' : 'GET');
  275. let fullUrl = buildRequestUrl(this.baseUrl, path, options.query);
  276. let data = options.data;
  277. if (!isUndefined(data) && method !== 'GET') {
  278. data = JSON.stringify(data);
  279. }
  280. // TODO(epurkhiser): Mimicking the old jQuery API, data could be a string /
  281. // object for GET requets. jQuery just sticks it onto the URL as query
  282. // parameters
  283. if (method === 'GET' && data) {
  284. const queryString = typeof data === 'string' ? data : jQuery.param(data);
  285. if (queryString.length > 0) {
  286. fullUrl = fullUrl + (fullUrl.indexOf('?') !== -1 ? '&' : '?') + queryString;
  287. }
  288. }
  289. const id = uniqueId();
  290. const startMarker = `api-request-start-${id}`;
  291. metric.mark({name: startMarker});
  292. const errorObject = new Error();
  293. /**
  294. * Called when the request completes with a 2xx status
  295. */
  296. const successHandler = (responseData: any, textStatus: string, xhr: JQueryXHR) => {
  297. metric.measure({
  298. name: 'app.api.request-success',
  299. start: startMarker,
  300. data: {status: xhr?.status},
  301. });
  302. if (!isUndefined(options.success)) {
  303. this.wrapCallback<[any, string, JQueryXHR]>(id, options.success)(
  304. responseData,
  305. textStatus,
  306. xhr
  307. );
  308. }
  309. };
  310. /**
  311. * Called when the request is non-2xx
  312. */
  313. const errorHandler = (resp: JQueryXHR, textStatus: string, errorThrown: string) => {
  314. metric.measure({
  315. name: 'app.api.request-error',
  316. start: startMarker,
  317. data: {status: resp?.status},
  318. });
  319. if (
  320. resp &&
  321. resp.status !== 0 &&
  322. resp.status !== 404 &&
  323. errorThrown !== 'Request was aborted'
  324. ) {
  325. run(Sentry =>
  326. Sentry.withScope(scope => {
  327. // `requestPromise` can pass its error object
  328. const preservedError = options.preservedError ?? errorObject;
  329. const errorObjectToUse = createRequestError(
  330. resp,
  331. preservedError.stack,
  332. method,
  333. path
  334. );
  335. errorObjectToUse.removeFrames(3);
  336. // Setting this to warning because we are going to capture all failed requests
  337. scope.setLevel(Severity.Warning);
  338. scope.setTag('http.statusCode', String(resp.status));
  339. scope.setTag('error.reason', errorThrown);
  340. Sentry.captureException(errorObjectToUse);
  341. })
  342. );
  343. }
  344. this.handleRequestError(
  345. {id, path, requestOptions: options},
  346. resp,
  347. textStatus,
  348. errorThrown
  349. );
  350. };
  351. /**
  352. * Called when the request completes
  353. */
  354. const completeHandler = (jqXHR: JQueryXHR, textStatus: string) =>
  355. this.wrapCallback<[JQueryXHR, string]>(
  356. id,
  357. options.complete,
  358. true
  359. )(jqXHR, textStatus);
  360. // AbortController is optional, though most browser should support it.
  361. const aborter =
  362. typeof AbortController !== 'undefined' ? new AbortController() : undefined;
  363. // GET requests may not have a body
  364. const body = method !== 'GET' ? data : undefined;
  365. const headers = new Headers({
  366. Accept: 'application/json; charset=utf-8',
  367. 'Content-Type': 'application/json',
  368. });
  369. // Do not set the X-CSRFToken header when making a request outside of the
  370. // current domain
  371. const absoluteUrl = new URL(fullUrl, window.location.origin);
  372. const isSameOrigin = window.location.origin === absoluteUrl.origin;
  373. if (!csrfSafeMethod(method) && isSameOrigin) {
  374. headers.set('X-CSRFToken', getCsrfToken());
  375. }
  376. const fetchRequest = fetch(fullUrl, {
  377. method,
  378. body,
  379. headers,
  380. credentials: 'same-origin',
  381. signal: aborter?.signal,
  382. });
  383. // XXX(epurkhiser): We're migrating off of jquery, so for now we have a
  384. // compatibility layer which mimics that of the jquery response objects.
  385. fetchRequest
  386. .then(async response => {
  387. // The Response's body can only be resolved/used at most once.
  388. // So we clone the response so we can resolve the body content as text content.
  389. // Response objects need to be cloned before its body can be used.
  390. const responseClone = response.clone();
  391. let responseJSON: any;
  392. let responseText: any;
  393. const {status, statusText} = response;
  394. let {ok} = response;
  395. let errorReason = 'Request not OK'; // the default error reason
  396. // Try to get text out of the response no matter the status
  397. try {
  398. responseText = await response.text();
  399. } catch (error) {
  400. ok = false;
  401. if (error.name === 'AbortError') {
  402. errorReason = 'Request was aborted';
  403. } else {
  404. errorReason = error.toString();
  405. }
  406. }
  407. const responseContentType = response.headers.get('content-type');
  408. const isResponseJSON = responseContentType?.includes('json');
  409. const isStatus3XX = status >= 300 && status < 400;
  410. if (status !== 204 && !isStatus3XX) {
  411. try {
  412. responseJSON = await responseClone.json();
  413. } catch (error) {
  414. if (error.name === 'AbortError') {
  415. ok = false;
  416. errorReason = 'Request was aborted';
  417. } else if (isResponseJSON && error instanceof SyntaxError) {
  418. // If the MIME type is `application/json` but decoding failed,
  419. // this should be an error.
  420. ok = false;
  421. errorReason = 'JSON parse error';
  422. }
  423. }
  424. }
  425. const emulatedJQueryXHR: any = {
  426. status,
  427. statusText,
  428. responseJSON,
  429. responseText,
  430. getResponseHeader: (header: string) => response.headers.get(header),
  431. };
  432. // Respect the response content-type header
  433. const responseData = isResponseJSON ? responseJSON : responseText;
  434. if (ok) {
  435. successHandler(responseData, statusText, emulatedJQueryXHR);
  436. } else {
  437. globalErrorHandlers.forEach(handler => handler(emulatedJQueryXHR));
  438. errorHandler(emulatedJQueryXHR, statusText, errorReason);
  439. }
  440. completeHandler(emulatedJQueryXHR, statusText);
  441. })
  442. .catch(err => {
  443. // Aborts are expected
  444. if (err?.name === 'AbortError') {
  445. return;
  446. }
  447. // The request failed for other reason
  448. run(Sentry =>
  449. Sentry.withScope(scope => {
  450. scope.setLevel(Severity.Warning);
  451. Sentry.captureException(err);
  452. })
  453. );
  454. });
  455. const request = new Request(fetchRequest, aborter);
  456. this.activeRequests[id] = request;
  457. return request;
  458. }
  459. requestPromise<IncludeAllArgsType extends boolean>(
  460. path: string,
  461. {
  462. includeAllArgs,
  463. ...options
  464. }: {includeAllArgs?: IncludeAllArgsType} & Readonly<RequestOptions> = {}
  465. ): Promise<
  466. IncludeAllArgsType extends true
  467. ? [any, string | undefined, JQueryXHR | undefined]
  468. : any
  469. > {
  470. // Create an error object here before we make any async calls so
  471. // that we have a helpful stack trace if it errors
  472. //
  473. // This *should* get logged to Sentry only if the promise rejection is not handled
  474. // (since SDK captures unhandled rejections). Ideally we explicitly ignore rejection
  475. // or handle with a user friendly error message
  476. const preservedError = new Error();
  477. return new Promise((resolve, reject) => {
  478. this.request(path, {
  479. ...options,
  480. preservedError,
  481. success: (data, textStatus, xhr) => {
  482. includeAllArgs ? resolve([data, textStatus, xhr] as any) : resolve(data);
  483. },
  484. error: (resp: JQueryXHR) => {
  485. const errorObjectToUse = createRequestError(
  486. resp,
  487. preservedError.stack,
  488. options.method,
  489. path
  490. );
  491. errorObjectToUse.removeFrames(2);
  492. // Although `this.request` logs all error responses, this error object can
  493. // potentially be logged by Sentry's unhandled rejection handler
  494. reject(errorObjectToUse);
  495. },
  496. });
  497. });
  498. }
  499. }