import {browserHistory} from 'react-router'; import * as Sentry from '@sentry/react'; import Cookies from 'js-cookie'; import isUndefined from 'lodash/isUndefined'; import * as qs from 'query-string'; import {openSudo, redirectToProject} from 'sentry/actionCreators/modal'; import {EXPERIMENTAL_SPA} from 'sentry/constants'; import { PROJECT_MOVED, SUDO_REQUIRED, SUPERUSER_REQUIRED, } from 'sentry/constants/apiErrorCodes'; import {metric} from 'sentry/utils/analytics'; import getCsrfToken from 'sentry/utils/getCsrfToken'; import {uniqueId} from 'sentry/utils/guid'; import createRequestError from 'sentry/utils/requestError/createRequestError'; export class Request { /** * Is the request still in flight */ alive: boolean; /** * Promise which will be resolved when the request has completed */ requestPromise: Promise; /** * AbortController to cancel the in-flight request. This will not be set in * unsupported browsers. */ aborter?: AbortController; constructor(requestPromise: Promise, aborter?: AbortController) { this.requestPromise = requestPromise; this.aborter = aborter; this.alive = true; } cancel() { this.alive = false; this.aborter?.abort(); metric('app.api.request-abort', 1); } } export type ResponseMeta = { /** * Get a header value from the response */ getResponseHeader: (header: string) => string | null; /** * The raw response */ rawResponse: Response; /** * The response body decoded from json */ responseJSON: R; /** * The string value of the response */ responseText: string; /** * The response status code */ status: Response['status']; /** * The response status code text */ statusText: Response['statusText']; }; /** * Check if the requested method does not require CSRF tokens */ function csrfSafeMethod(method?: string) { // these HTTP methods do not require CSRF protection return /^(GET|HEAD|OPTIONS|TRACE)$/.test(method ?? ''); } // TODO: Need better way of identifying anonymous pages that don't trigger redirect const ALLOWED_ANON_PAGES = [ /^\/accept\//, /^\/share\//, /^\/auth\/login\//, /^\/join-request\//, ]; /** * Return true if we should skip calling the normal error handler */ const globalErrorHandlers: ((resp: ResponseMeta) => boolean)[] = []; export const initApiClientErrorHandling = () => globalErrorHandlers.push((resp: ResponseMeta) => { const pageAllowsAnon = ALLOWED_ANON_PAGES.find(regex => regex.test(window.location.pathname) ); // Ignore error unless it is a 401 if (!resp || resp.status !== 401 || pageAllowsAnon) { return false; } const code = resp?.responseJSON?.detail?.code; const extra = resp?.responseJSON?.detail?.extra; // 401s can also mean sudo is required or it's a request that is allowed to fail // Ignore if these are the cases if ( [ 'sudo-required', 'ignore', '2fa-required', 'app-connect-authentication-error', ].includes(code) ) { return false; } // If user must login via SSO, redirect to org login page if (code === 'sso-required') { window.location.assign(extra.loginUrl); return true; } if (code === 'member-disabled-over-limit') { browserHistory.replace(extra.next); return true; } // Otherwise, the user has become unauthenticated. Send them to auth Cookies.set('session_expired', '1'); if (EXPERIMENTAL_SPA) { browserHistory.replace('/auth/login/'); } else { window.location.reload(); } return true; }); /** * Construct a full request URL */ function buildRequestUrl(baseUrl: string, path: string, query: RequestOptions['query']) { let params: string; try { params = qs.stringify(query ?? []); } catch (err) { Sentry.withScope(scope => { scope.setExtra('path', path); scope.setExtra('query', query); Sentry.captureException(err); }); throw err; } // Append the baseUrl let fullUrl = path.includes(baseUrl) ? path : baseUrl + path; // Append query parameters if (params) { fullUrl += fullUrl.includes('?') ? `&${params}` : `?${params}`; } return fullUrl; } /** * Check if the API response says project has been renamed. If so, redirect * user to new project slug */ // TODO(ts): refine this type later export function hasProjectBeenRenamed(response: ResponseMeta) { const code = response?.responseJSON?.detail?.code; // XXX(billy): This actually will never happen because we can't intercept the 302 // jQuery ajax will follow the redirect by default... // // TODO(epurkhiser): We use fetch now, is the above comment still true? if (code !== PROJECT_MOVED) { return false; } const slug = response?.responseJSON?.detail?.extra?.slug; redirectToProject(slug); return true; } // TODO(ts): move this somewhere export type APIRequestMethod = 'POST' | 'GET' | 'DELETE' | 'PUT'; type FunctionCallback = (...args: Args) => void; export type RequestCallbacks = { /** * Callback for the request completing (success or error) */ complete?: (resp: ResponseMeta, textStatus: string) => void; /** * Callback for the request failing with an error */ // TODO(ts): Update this when sentry is mostly migrated to TS error?: FunctionCallback; /** * Callback for the request completing successfully */ success?: (data: any, textStatus?: string, resp?: ResponseMeta) => void; }; export type RequestOptions = RequestCallbacks & { /** * Values to attach to the body of the request. */ data?: any; /** * The HTTP method to use when making the API request */ method?: APIRequestMethod; /** * Because of the async nature of API requests, errors will happen outside of * the stack that initated the request. a preservedError can be passed to * coalesce the stacks together. */ preservedError?: Error; /** * Query parameters to add to the requested URL. */ query?: Record; }; type ClientOptions = { /** * The base URL path to prepend to API request URIs. */ baseUrl?: string; }; type HandleRequestErrorOptions = { id: string; path: string; requestOptions: Readonly; }; /** * The API client is used to make HTTP requests to Sentry's backend. * * This is they preferred way to talk to the backend. */ export class Client { baseUrl: string; activeRequests: Record; constructor(options: ClientOptions = {}) { this.baseUrl = options.baseUrl ?? '/api/0'; this.activeRequests = {}; } wrapCallback( id: string, func: FunctionCallback | undefined, cleanup: boolean = false ) { return (...args: T) => { const req = this.activeRequests[id]; if (cleanup === true) { delete this.activeRequests[id]; } if (!req?.alive) { return; } // Check if API response is a 302 -- means project slug was renamed and user // needs to be redirected // @ts-expect-error if (hasProjectBeenRenamed(...args)) { return; } if (isUndefined(func)) { return; } // Call success callback return func.apply(req, args); // eslint-disable-line }; } /** * Attempt to cancel all active fetch requests */ clear() { Object.values(this.activeRequests).forEach(r => r.cancel()); } handleRequestError( {id, path, requestOptions}: HandleRequestErrorOptions, response: ResponseMeta, textStatus: string, errorThrown: string ) { const code = response?.responseJSON?.detail?.code; const isSudoRequired = code === SUDO_REQUIRED || code === SUPERUSER_REQUIRED; let didSuccessfullyRetry = false; if (isSudoRequired) { openSudo({ isSuperuser: code === SUPERUSER_REQUIRED, sudo: code === SUDO_REQUIRED, retryRequest: async () => { try { const data = await this.requestPromise(path, requestOptions); requestOptions.success?.(data); didSuccessfullyRetry = true; } catch (err) { requestOptions.error?.(err); } }, onClose: () => // If modal was closed, then forward the original response !didSuccessfullyRetry && requestOptions.error?.(response), }); return; } // Call normal error callback const errorCb = this.wrapCallback<[ResponseMeta, string, string]>( id, requestOptions.error ); errorCb?.(response, textStatus, errorThrown); } /** * Initate a request to the backend API. * * Consider using `requestPromise` for the async Promise version of this method. */ request(path: string, options: Readonly = {}): Request { const method = options.method || (options.data ? 'POST' : 'GET'); let fullUrl = buildRequestUrl(this.baseUrl, path, options.query); let data = options.data; if (!isUndefined(data) && method !== 'GET') { data = JSON.stringify(data); } // TODO(epurkhiser): Mimicking the old jQuery API, data could be a string / // object for GET requets. jQuery just sticks it onto the URL as query // parameters if (method === 'GET' && data) { const queryString = typeof data === 'string' ? data : qs.stringify(data); if (queryString.length > 0) { fullUrl = fullUrl + (fullUrl.indexOf('?') !== -1 ? '&' : '?') + queryString; } } const id = uniqueId(); const startMarker = `api-request-start-${id}`; metric.mark({name: startMarker}); /** * Called when the request completes with a 2xx status */ const successHandler = ( resp: ResponseMeta, textStatus: string, responseData: any ) => { metric.measure({ name: 'app.api.request-success', start: startMarker, data: {status: resp?.status}, }); if (!isUndefined(options.success)) { this.wrapCallback<[any, string, ResponseMeta]>(id, options.success)( responseData, textStatus, resp ); } }; /** * Called when the request is non-2xx */ const errorHandler = ( resp: ResponseMeta, textStatus: string, errorThrown: string ) => { metric.measure({ name: 'app.api.request-error', start: startMarker, data: {status: resp?.status}, }); this.handleRequestError( {id, path, requestOptions: options}, resp, textStatus, errorThrown ); }; /** * Called when the request completes */ const completeHandler = (resp: ResponseMeta, textStatus: string) => this.wrapCallback<[ResponseMeta, string]>( id, options.complete, true )(resp, textStatus); // AbortController is optional, though most browser should support it. const aborter = typeof AbortController !== 'undefined' ? new AbortController() : undefined; // GET requests may not have a body const body = method !== 'GET' ? data : undefined; const headers = new Headers({ Accept: 'application/json; charset=utf-8', 'Content-Type': 'application/json', }); // Do not set the X-CSRFToken header when making a request outside of the // current domain const absoluteUrl = new URL(fullUrl, window.location.origin); const isSameOrigin = window.location.origin === absoluteUrl.origin; if (!csrfSafeMethod(method) && isSameOrigin) { headers.set('X-CSRFToken', getCsrfToken()); } const fetchRequest = fetch(fullUrl, { method, body, headers, credentials: 'include', signal: aborter?.signal, }); // XXX(epurkhiser): We migrated off of jquery, so for now we have a // compatibility layer which mimics that of the jquery response objects. fetchRequest .then(async response => { // The Response's body can only be resolved/used at most once. // So we clone the response so we can resolve the body content as text content. // Response objects need to be cloned before its body can be used. let responseJSON: any; let responseText: any; const {status, statusText} = response; let {ok} = response; let errorReason = 'Request not OK'; // the default error reason // Try to get text out of the response no matter the status try { responseText = await response.clone().text(); } catch (error) { ok = false; if (error.name === 'AbortError') { errorReason = 'Request was aborted'; } else { errorReason = error.toString(); } } const responseContentType = response.clone().headers.get('content-type'); const isResponseJSON = responseContentType?.includes('json'); const isStatus3XX = status >= 300 && status < 400; if (status !== 204 && !isStatus3XX) { try { responseJSON = await response.clone().json(); } catch (error) { if (error.name === 'AbortError') { ok = false; errorReason = 'Request was aborted'; } else if (isResponseJSON && error instanceof SyntaxError) { // If the MIME type is `application/json` but decoding failed, // this should be an error. ok = false; errorReason = 'JSON parse error'; } } } const responseMeta: ResponseMeta = { status, statusText, responseJSON, responseText, getResponseHeader: (header: string) => response.headers.get(header), rawResponse: response.clone(), }; // Respect the response content-type header const responseData = isResponseJSON ? responseJSON : responseText; if (ok) { successHandler(responseMeta, statusText, responseData); } else { const shouldSkipErrorHandler = globalErrorHandlers.map(handler => handler(responseMeta)).filter(Boolean) .length > 0; if (!shouldSkipErrorHandler) { errorHandler(responseMeta, statusText, errorReason); } } completeHandler(responseMeta, statusText); }) .catch(() => { // Ignore all failed requests }); const request = new Request(fetchRequest, aborter); this.activeRequests[id] = request; return request; } requestPromise( path: string, { includeAllArgs, ...options }: {includeAllArgs?: IncludeAllArgsType} & Readonly = {} ): Promise< IncludeAllArgsType extends true ? [any, string | undefined, ResponseMeta | undefined] : any > { // Create an error object here before we make any async calls so that we // have a helpful stack trace if it errors // // This *should* get logged to Sentry only if the promise rejection is not handled // (since SDK captures unhandled rejections). Ideally we explicitly ignore rejection // or handle with a user friendly error message const preservedError = new Error(); return new Promise((resolve, reject) => this.request(path, { ...options, preservedError, success: (data, textStatus, resp) => { if (includeAllArgs) { resolve([data, textStatus, resp] as any); } else { resolve(data); } }, error: (resp: ResponseMeta) => { const errorObjectToUse = createRequestError( resp, preservedError.stack, options.method, path ); errorObjectToUse.removeFrames(2); // Although `this.request` logs all error responses, this error object can // potentially be logged by Sentry's unhandled rejection handler reject(errorObjectToUse); }, }) ); } }