api.tsx 18 KB

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