api.tsx 17 KB

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