api.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  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 controlsilopatterns from 'sentry/data/controlsiloUrlPatterns';
  13. import {metric} from 'sentry/utils/analytics';
  14. import getCsrfToken from 'sentry/utils/getCsrfToken';
  15. import {uniqueId} from 'sentry/utils/guid';
  16. import RequestError from 'sentry/utils/requestError/requestError';
  17. import {sanitizePath} from 'sentry/utils/requestError/sanitizePath';
  18. import ConfigStore from './stores/configStore';
  19. import OrganizationStore from './stores/organizationStore';
  20. export class Request {
  21. /**
  22. * Is the request still in flight
  23. */
  24. alive: boolean;
  25. /**
  26. * Promise which will be resolved when the request has completed
  27. */
  28. requestPromise: Promise<Response>;
  29. /**
  30. * AbortController to cancel the in-flight request. This will not be set in
  31. * unsupported browsers.
  32. */
  33. aborter?: AbortController;
  34. constructor(requestPromise: Promise<Response>, aborter?: AbortController) {
  35. this.requestPromise = requestPromise;
  36. this.aborter = aborter;
  37. this.alive = true;
  38. }
  39. cancel() {
  40. this.alive = false;
  41. this.aborter?.abort();
  42. metric('app.api.request-abort', 1);
  43. }
  44. }
  45. export type ApiResult<Data = any> = [
  46. data: Data,
  47. statusText: string | undefined,
  48. resp: ResponseMeta | undefined,
  49. ];
  50. export type ResponseMeta<R = any> = {
  51. /**
  52. * Get a header value from the response
  53. */
  54. getResponseHeader: (header: string) => string | null;
  55. /**
  56. * The response body decoded from json
  57. */
  58. responseJSON: R;
  59. /**
  60. * The string value of the response
  61. */
  62. responseText: string;
  63. /**
  64. * The response status code
  65. */
  66. status: Response['status'];
  67. /**
  68. * The response status code text
  69. */
  70. statusText: Response['statusText'];
  71. };
  72. /**
  73. * Check if the requested method does not require CSRF tokens
  74. */
  75. function csrfSafeMethod(method?: string): boolean {
  76. // these HTTP methods do not require CSRF protection
  77. return /^(GET|HEAD|OPTIONS|TRACE)$/.test(method ?? '');
  78. }
  79. /**
  80. * Check if we a request is going to the same or similar origin.
  81. * similar origins are those that share an ancestor. Example `sentry.sentry.io` and `us.sentry.io`
  82. * are similar origins, but sentry.sentry.io and sentry.example.io are not.
  83. */
  84. export function isSimilarOrigin(target: string, origin: string): boolean {
  85. const targetUrl = new URL(target, origin);
  86. const originUrl = new URL(origin);
  87. // If one of the domains is a child of the other.
  88. if (
  89. originUrl.hostname.endsWith(targetUrl.hostname) ||
  90. targetUrl.hostname.endsWith(originUrl.hostname)
  91. ) {
  92. return true;
  93. }
  94. // Check if the target and origin are on sibiling subdomains.
  95. const targetHost = targetUrl.hostname.split('.');
  96. const originHost = originUrl.hostname.split('.');
  97. // Remove the subdomains. If don't have at least 2 segments we aren't subdomains.
  98. targetHost.shift();
  99. originHost.shift();
  100. if (targetHost.length < 2 || originHost.length < 2) {
  101. return false;
  102. }
  103. return targetHost.join('.') === originHost.join('.');
  104. }
  105. // TODO: Need better way of identifying anonymous pages that don't trigger redirect
  106. const ALLOWED_ANON_PAGES = [
  107. /^\/accept\//,
  108. /^\/share\//,
  109. /^\/auth\/login\//,
  110. /^\/join-request\//,
  111. ];
  112. /**
  113. * Return true if we should skip calling the normal error handler
  114. */
  115. const globalErrorHandlers: ((resp: ResponseMeta) => boolean)[] = [];
  116. export const initApiClientErrorHandling = () =>
  117. globalErrorHandlers.push((resp: ResponseMeta) => {
  118. const pageAllowsAnon = ALLOWED_ANON_PAGES.find(regex =>
  119. regex.test(window.location.pathname)
  120. );
  121. // Ignore error unless it is a 401
  122. if (!resp || resp.status !== 401 || pageAllowsAnon) {
  123. return false;
  124. }
  125. const code = resp?.responseJSON?.detail?.code;
  126. const extra = resp?.responseJSON?.detail?.extra;
  127. // 401s can also mean sudo is required or it's a request that is allowed to fail
  128. // Ignore if these are the cases
  129. if (
  130. [
  131. 'sudo-required',
  132. 'ignore',
  133. '2fa-required',
  134. 'app-connect-authentication-error',
  135. ].includes(code)
  136. ) {
  137. return false;
  138. }
  139. // If user must login via SSO, redirect to org login page
  140. if (code === 'sso-required') {
  141. window.location.assign(extra.loginUrl);
  142. return true;
  143. }
  144. if (code === 'member-disabled-over-limit') {
  145. browserHistory.replace(extra.next);
  146. return true;
  147. }
  148. // Otherwise, the user has become unauthenticated. Send them to auth
  149. Cookies.set('session_expired', '1');
  150. if (EXPERIMENTAL_SPA) {
  151. browserHistory.replace('/auth/login/');
  152. } else {
  153. window.location.reload();
  154. }
  155. return true;
  156. });
  157. /**
  158. * Construct a full request URL
  159. */
  160. function buildRequestUrl(baseUrl: string, path: string, options: RequestOptions) {
  161. let params: string;
  162. try {
  163. params = qs.stringify(options.query ?? []);
  164. } catch (err) {
  165. Sentry.withScope(scope => {
  166. scope.setExtra('path', path);
  167. scope.setExtra('query', options.query);
  168. Sentry.captureException(err);
  169. });
  170. throw err;
  171. }
  172. // Append the baseUrl if required
  173. let fullUrl = path.includes(baseUrl) ? path : baseUrl + path;
  174. // Apply path and domain transforms for hybrid-cloud
  175. fullUrl = resolveHostname(fullUrl, options.host);
  176. // Append query parameters
  177. if (params) {
  178. fullUrl += fullUrl.includes('?') ? `&${params}` : `?${params}`;
  179. }
  180. return fullUrl;
  181. }
  182. /**
  183. * Check if the API response says project has been renamed. If so, redirect
  184. * user to new project slug
  185. */
  186. // TODO(ts): refine this type later
  187. export function hasProjectBeenRenamed(response: ResponseMeta) {
  188. const code = response?.responseJSON?.detail?.code;
  189. // XXX(billy): This actually will never happen because we can't intercept the 302
  190. // jQuery ajax will follow the redirect by default...
  191. //
  192. // TODO(epurkhiser): We use fetch now, is the above comment still true?
  193. if (code !== PROJECT_MOVED) {
  194. return false;
  195. }
  196. const slug = response?.responseJSON?.detail?.extra?.slug;
  197. redirectToProject(slug);
  198. return true;
  199. }
  200. // TODO(ts): move this somewhere
  201. export type APIRequestMethod = 'POST' | 'GET' | 'DELETE' | 'PUT';
  202. type FunctionCallback<Args extends any[] = any[]> = (...args: Args) => void;
  203. export type RequestCallbacks = {
  204. /**
  205. * Callback for the request completing (success or error)
  206. */
  207. complete?: (resp: ResponseMeta, textStatus: string) => void;
  208. /**
  209. * Callback for the request failing with an error
  210. */
  211. // TODO(ts): Update this when sentry is mostly migrated to TS
  212. error?: FunctionCallback;
  213. /**
  214. * Callback for the request completing successfully
  215. */
  216. success?: (data: any, textStatus?: string, resp?: ResponseMeta) => void;
  217. };
  218. export type RequestOptions = RequestCallbacks & {
  219. /**
  220. * Values to attach to the body of the request.
  221. */
  222. data?: any;
  223. /**
  224. * Headers add to the request.
  225. */
  226. headers?: Record<string, string>;
  227. /**
  228. * The host the request should be made to.
  229. */
  230. host?: string;
  231. /**
  232. * The HTTP method to use when making the API request
  233. */
  234. method?: APIRequestMethod;
  235. /**
  236. * Because of the async nature of API requests, errors will happen outside of
  237. * the stack that initated the request. a preservedError can be passed to
  238. * coalesce the stacks together.
  239. */
  240. preservedError?: Error;
  241. /**
  242. * Query parameters to add to the requested URL.
  243. */
  244. query?: Record<string, any>;
  245. };
  246. type ClientOptions = {
  247. /**
  248. * The base URL path to prepend to API request URIs.
  249. */
  250. baseUrl?: string;
  251. /**
  252. * Credentials policy to apply to each request
  253. */
  254. credentials?: RequestCredentials;
  255. /**
  256. * Base set of headers to apply to each request
  257. */
  258. headers?: HeadersInit;
  259. };
  260. type HandleRequestErrorOptions = {
  261. id: string;
  262. path: string;
  263. requestOptions: Readonly<RequestOptions>;
  264. };
  265. /**
  266. * The API client is used to make HTTP requests to Sentry's backend.
  267. *
  268. * This is they preferred way to talk to the backend.
  269. */
  270. export class Client {
  271. baseUrl: string;
  272. activeRequests: Record<string, Request>;
  273. headers: HeadersInit;
  274. credentials?: RequestCredentials;
  275. static JSON_HEADERS = {
  276. Accept: 'application/json; charset=utf-8',
  277. 'Content-Type': 'application/json',
  278. };
  279. constructor(options: ClientOptions = {}) {
  280. this.baseUrl = options.baseUrl ?? '/api/0';
  281. this.headers = options.headers ?? Client.JSON_HEADERS;
  282. this.activeRequests = {};
  283. this.credentials = options.credentials ?? 'include';
  284. }
  285. wrapCallback<T extends any[]>(
  286. id: string,
  287. func: FunctionCallback<T> | undefined,
  288. cleanup: boolean = false
  289. ) {
  290. return (...args: T) => {
  291. const req = this.activeRequests[id];
  292. if (cleanup === true) {
  293. delete this.activeRequests[id];
  294. }
  295. if (!req?.alive) {
  296. return undefined;
  297. }
  298. // Check if API response is a 302 -- means project slug was renamed and user
  299. // needs to be redirected
  300. // @ts-expect-error
  301. if (hasProjectBeenRenamed(...args)) {
  302. return undefined;
  303. }
  304. // Call success callback
  305. return func?.apply(req, args);
  306. };
  307. }
  308. /**
  309. * Attempt to cancel all active fetch requests
  310. */
  311. clear() {
  312. Object.values(this.activeRequests).forEach(r => r.cancel());
  313. }
  314. handleRequestError(
  315. {id, path, requestOptions}: HandleRequestErrorOptions,
  316. response: ResponseMeta,
  317. textStatus: string,
  318. errorThrown: string
  319. ) {
  320. const code = response?.responseJSON?.detail?.code;
  321. const isSudoRequired = code === SUDO_REQUIRED || code === SUPERUSER_REQUIRED;
  322. let didSuccessfullyRetry = false;
  323. if (isSudoRequired) {
  324. openSudo({
  325. isSuperuser: code === SUPERUSER_REQUIRED,
  326. sudo: code === SUDO_REQUIRED,
  327. retryRequest: async () => {
  328. try {
  329. const data = await this.requestPromise(path, requestOptions);
  330. requestOptions.success?.(data);
  331. didSuccessfullyRetry = true;
  332. } catch (err) {
  333. requestOptions.error?.(err);
  334. }
  335. },
  336. onClose: () =>
  337. // If modal was closed, then forward the original response
  338. !didSuccessfullyRetry && requestOptions.error?.(response),
  339. });
  340. return;
  341. }
  342. // Call normal error callback
  343. const errorCb = this.wrapCallback<[ResponseMeta, string, string]>(
  344. id,
  345. requestOptions.error
  346. );
  347. errorCb?.(response, textStatus, errorThrown);
  348. }
  349. /**
  350. * Initate a request to the backend API.
  351. *
  352. * Consider using `requestPromise` for the async Promise version of this method.
  353. */
  354. request(path: string, options: Readonly<RequestOptions> = {}): Request {
  355. const method = options.method || (options.data ? 'POST' : 'GET');
  356. let fullUrl = buildRequestUrl(this.baseUrl, path, options);
  357. let data = options.data;
  358. if (data !== undefined && method !== 'GET') {
  359. data = JSON.stringify(data);
  360. }
  361. // TODO(epurkhiser): Mimicking the old jQuery API, data could be a string /
  362. // object for GET requets. jQuery just sticks it onto the URL as query
  363. // parameters
  364. if (method === 'GET' && data) {
  365. const queryString = typeof data === 'string' ? data : qs.stringify(data);
  366. if (queryString.length > 0) {
  367. fullUrl = fullUrl + (fullUrl.includes('?') ? '&' : '?') + queryString;
  368. }
  369. }
  370. const id = uniqueId();
  371. const startMarker = `api-request-start-${id}`;
  372. metric.mark({name: startMarker});
  373. /**
  374. * Called when the request completes with a 2xx status
  375. */
  376. const successHandler = (
  377. resp: ResponseMeta,
  378. textStatus: string,
  379. responseData: any
  380. ) => {
  381. metric.measure({
  382. name: 'app.api.request-success',
  383. start: startMarker,
  384. data: {status: resp?.status},
  385. });
  386. if (options.success !== undefined) {
  387. this.wrapCallback<[any, string, ResponseMeta]>(id, options.success)(
  388. responseData,
  389. textStatus,
  390. resp
  391. );
  392. }
  393. };
  394. /**
  395. * Called when the request is non-2xx
  396. */
  397. const errorHandler = (
  398. resp: ResponseMeta,
  399. textStatus: string,
  400. errorThrown: string
  401. ) => {
  402. metric.measure({
  403. name: 'app.api.request-error',
  404. start: startMarker,
  405. data: {status: resp?.status},
  406. });
  407. this.handleRequestError(
  408. {id, path, requestOptions: options},
  409. resp,
  410. textStatus,
  411. errorThrown
  412. );
  413. };
  414. /**
  415. * Called when the request completes
  416. */
  417. const completeHandler = (resp: ResponseMeta, textStatus: string) =>
  418. this.wrapCallback<[ResponseMeta, string]>(
  419. id,
  420. options.complete,
  421. true
  422. )(resp, textStatus);
  423. // AbortController is optional, though most browser should support it.
  424. const aborter =
  425. typeof AbortController !== 'undefined' ? new AbortController() : undefined;
  426. // GET requests may not have a body
  427. const body = method !== 'GET' ? data : undefined;
  428. const headers = new Headers(this.headers);
  429. // Do not set the X-CSRFToken header when making a request outside of the
  430. // current domain. Because we use subdomains we loosely compare origins
  431. if (!csrfSafeMethod(method) && isSimilarOrigin(fullUrl, window.location.origin)) {
  432. headers.set('X-CSRFToken', getCsrfToken());
  433. }
  434. const fetchRequest = fetch(fullUrl, {
  435. method,
  436. body,
  437. headers,
  438. credentials: this.credentials,
  439. signal: aborter?.signal,
  440. });
  441. // XXX(epurkhiser): We migrated off of jquery, so for now we have a
  442. // compatibility layer which mimics that of the jquery response objects.
  443. fetchRequest
  444. .then(
  445. async response => {
  446. // The Response's body can only be resolved/used at most once.
  447. // So we clone the response so we can resolve the body content as text content.
  448. // Response objects need to be cloned before its body can be used.
  449. let responseJSON: any;
  450. let responseText: any;
  451. const {status, statusText} = response;
  452. let {ok} = response;
  453. let errorReason = 'Request not OK'; // the default error reason
  454. let twoHundredErrorReason;
  455. // Try to get text out of the response no matter the status
  456. try {
  457. responseText = await response.text();
  458. } catch (error) {
  459. twoHundredErrorReason = 'Failed awaiting response.text()';
  460. ok = false;
  461. if (error.name === 'AbortError') {
  462. errorReason = 'Request was aborted';
  463. } else {
  464. errorReason = error.toString();
  465. }
  466. }
  467. const responseContentType = response.headers.get('content-type');
  468. const isResponseJSON = responseContentType?.includes('json');
  469. const isStatus3XX = status >= 300 && status < 400;
  470. if (status !== 204 && !isStatus3XX) {
  471. try {
  472. responseJSON = JSON.parse(responseText);
  473. } catch (error) {
  474. twoHundredErrorReason = 'Failed trying to parse responseText';
  475. if (error.name === 'AbortError') {
  476. ok = false;
  477. errorReason = 'Request was aborted';
  478. } else if (isResponseJSON && error instanceof SyntaxError) {
  479. // If the MIME type is `application/json` but decoding failed,
  480. // this should be an error.
  481. ok = false;
  482. errorReason = 'JSON parse error';
  483. }
  484. }
  485. }
  486. const responseMeta: ResponseMeta = {
  487. status,
  488. statusText,
  489. responseJSON,
  490. responseText,
  491. getResponseHeader: (header: string) => response.headers.get(header),
  492. };
  493. // Respect the response content-type header
  494. const responseData = isResponseJSON ? responseJSON : responseText;
  495. if (ok) {
  496. successHandler(responseMeta, statusText, responseData);
  497. } else {
  498. // There's no reason we should be here with a 200 response, but we get
  499. // tons of events from this codepath with a 200 status nonetheless.
  500. // Until we know why, let's do what is essentially some very fancy print debugging.
  501. if (status === 200 && responseText) {
  502. const parameterizedPath = sanitizePath(path);
  503. const message = '200 treated as error';
  504. const scope = new Sentry.Scope();
  505. scope.setTags({endpoint: `${method} ${parameterizedPath}`, errorReason});
  506. scope.setExtras({
  507. twoHundredErrorReason,
  508. responseJSON,
  509. responseText,
  510. responseContentType,
  511. errorReason,
  512. });
  513. // Make sure all of these errors group, so we don't produce a bunch of noise
  514. scope.setFingerprint([message]);
  515. Sentry.captureException(
  516. new Error(`${message}: ${method} ${parameterizedPath}`),
  517. scope
  518. );
  519. }
  520. const shouldSkipErrorHandler =
  521. globalErrorHandlers.map(handler => handler(responseMeta)).filter(Boolean)
  522. .length > 0;
  523. if (!shouldSkipErrorHandler) {
  524. errorHandler(responseMeta, statusText, errorReason);
  525. }
  526. }
  527. completeHandler(responseMeta, statusText);
  528. },
  529. () => {
  530. // Ignore failed fetch calls or errors in the fetch request itself (e.g. cancelled requests)
  531. // Not related to errors in responses
  532. }
  533. )
  534. .catch(error => {
  535. // eslint-disable-next-line no-console
  536. console.error(error);
  537. Sentry.captureException(error);
  538. });
  539. const request = new Request(fetchRequest, aborter);
  540. this.activeRequests[id] = request;
  541. return request;
  542. }
  543. requestPromise<IncludeAllArgsType extends boolean>(
  544. path: string,
  545. {
  546. includeAllArgs,
  547. ...options
  548. }: {includeAllArgs?: IncludeAllArgsType} & Readonly<RequestOptions> = {}
  549. ): Promise<IncludeAllArgsType extends true ? ApiResult : any> {
  550. // Create an error object here before we make any async calls so that we
  551. // have a helpful stack trace if it errors
  552. //
  553. // This *should* get logged to Sentry only if the promise rejection is not handled
  554. // (since SDK captures unhandled rejections). Ideally we explicitly ignore rejection
  555. // or handle with a user friendly error message
  556. const preservedError = new Error('API Request Error');
  557. return new Promise((resolve, reject) =>
  558. this.request(path, {
  559. ...options,
  560. preservedError,
  561. success: (data, textStatus, resp) => {
  562. if (includeAllArgs) {
  563. resolve([data, textStatus, resp] as any);
  564. } else {
  565. resolve(data);
  566. }
  567. },
  568. error: (resp: ResponseMeta) => {
  569. const errorObjectToUse = new RequestError(
  570. options.method,
  571. path,
  572. preservedError,
  573. resp
  574. );
  575. // Although `this.request` logs all error responses, this error object can
  576. // potentially be logged by Sentry's unhandled rejection handler
  577. reject(errorObjectToUse);
  578. },
  579. })
  580. );
  581. }
  582. }
  583. export function resolveHostname(path: string, hostname?: string): string {
  584. const storeState = OrganizationStore.get();
  585. const configLinks = ConfigStore.get('links');
  586. hostname = hostname ?? '';
  587. if (!hostname && storeState.organization?.features.includes('frontend-domainsplit')) {
  588. const isControlSilo = detectControlSiloPath(path);
  589. if (!isControlSilo && configLinks.regionUrl) {
  590. hostname = configLinks.regionUrl;
  591. }
  592. if (isControlSilo && configLinks.sentryUrl) {
  593. hostname = configLinks.sentryUrl;
  594. }
  595. }
  596. // If we're making a request to the applications' root
  597. // domain, we can drop the domain as webpack devserver will add one.
  598. // TODO(hybridcloud) This can likely be removed when sentry.types.region.Region.to_url()
  599. // loses the monolith mode condition.
  600. if (window.__SENTRY_DEV_UI && hostname === configLinks.sentryUrl) {
  601. hostname = '';
  602. }
  603. // When running as yarn dev-ui we can't spread requests across domains because
  604. // of CORS. Instead we extract the subdomain from the hostname
  605. // and prepend the URL with `/region/$name` so that webpack-devserver proxy
  606. // can route requests to the regions.
  607. if (hostname && window.__SENTRY_DEV_UI) {
  608. const domainpattern = /https?\:\/\/([^.]*)\.sentry\.io/;
  609. const domainmatch = hostname.match(domainpattern);
  610. if (domainmatch) {
  611. hostname = '';
  612. path = `/region/${domainmatch[1]}${path}`;
  613. }
  614. }
  615. if (hostname) {
  616. path = `${hostname}${path}`;
  617. }
  618. return path;
  619. }
  620. function detectControlSiloPath(path: string): boolean {
  621. path = path.startsWith('/') ? path.substring(1) : path;
  622. for (const pattern of controlsilopatterns) {
  623. if (pattern.test(path)) {
  624. return true;
  625. }
  626. }
  627. return false;
  628. }