api.tsx 18 KB

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