api.tsx 16 KB

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