api.tsx 16 KB

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