api.tsx 17 KB

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