api.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import isEqual from 'lodash/isEqual';
  2. import type * as ApiNamespace from 'sentry/api';
  3. import RequestError from 'sentry/utils/requestError/requestError';
  4. const RealApi: typeof ApiNamespace = jest.requireActual('sentry/api');
  5. export const initApiClientErrorHandling = RealApi.initApiClientErrorHandling;
  6. export const hasProjectBeenRenamed = RealApi.hasProjectBeenRenamed;
  7. const respond = (
  8. asyncDelay: AsyncDelay,
  9. fn: FunctionCallback | undefined,
  10. ...args: any[]
  11. ): void => {
  12. if (!fn) {
  13. return;
  14. }
  15. if (asyncDelay !== undefined) {
  16. setTimeout(() => fn(...args), asyncDelay);
  17. return;
  18. }
  19. fn(...args);
  20. };
  21. type FunctionCallback<Args extends any[] = any[]> = (...args: Args) => void;
  22. /**
  23. * Callables for matching requests based on arbitrary conditions.
  24. */
  25. type MatchCallable = (url: string, options: ApiNamespace.RequestOptions) => boolean;
  26. type AsyncDelay = undefined | number;
  27. interface ResponseType extends ApiNamespace.ResponseMeta {
  28. body: any;
  29. callCount: 0;
  30. headers: Record<string, string>;
  31. host: string;
  32. match: MatchCallable[];
  33. method: string;
  34. statusCode: number;
  35. url: string;
  36. /**
  37. * Whether to return mocked api responses directly, or with a setTimeout delay.
  38. *
  39. * Set to `null` to disable the async delay
  40. * Set to a `number` which will be the amount of time (ms) for the delay
  41. *
  42. * This will override `MockApiClient.asyncDelay` for this request.
  43. */
  44. asyncDelay?: AsyncDelay;
  45. query?: Record<string, string | number | boolean | string[] | number[]>;
  46. }
  47. type MockResponse = [resp: ResponseType, mock: jest.Mock];
  48. /**
  49. * Compare two records. `want` is all the entries we want to have the same value in `check`
  50. */
  51. function compareRecord(want: Record<string, any>, check: Record<string, any>): boolean {
  52. for (const entry of Object.entries(want)) {
  53. const [key, value] = entry;
  54. if (!isEqual(check[key], value)) {
  55. return false;
  56. }
  57. }
  58. return true;
  59. }
  60. afterEach(() => {
  61. // if any errors are caught we console.error them
  62. const errors = Object.values(Client.errors);
  63. if (errors.length > 0) {
  64. for (const err of errors) {
  65. // eslint-disable-next-line no-console
  66. console.error(err);
  67. }
  68. Client.errors = {};
  69. }
  70. // Mock responses are removed between tests
  71. Client.clearMockResponses();
  72. });
  73. class Client implements ApiNamespace.Client {
  74. activeRequests: Record<string, ApiNamespace.Request> = {};
  75. baseUrl = '';
  76. // uses the default client json headers. Sadly, we cannot refernce the real client
  77. // because it will cause a circular dependency and explode, hence the copy/paste
  78. headers = {
  79. Accept: 'application/json; charset=utf-8',
  80. 'Content-Type': 'application/json',
  81. };
  82. static mockResponses: MockResponse[] = [];
  83. /**
  84. * Whether to return mocked api responses directly, or with a setTimeout delay.
  85. *
  86. * Set to `null` to disable the async delay
  87. * Set to a `number` which will be the amount of time (ms) for the delay
  88. *
  89. * This is the global/default value. `addMockResponse` can override per request.
  90. */
  91. static asyncDelay: AsyncDelay = undefined;
  92. static clearMockResponses() {
  93. Client.mockResponses = [];
  94. }
  95. /**
  96. * Create a query string match callable.
  97. *
  98. * Only keys/values defined in `query` are checked.
  99. */
  100. static matchQuery(query: Record<string, any>): MatchCallable {
  101. const queryMatcher: MatchCallable = (_url, options) => {
  102. return compareRecord(query, options.query ?? {});
  103. };
  104. return queryMatcher;
  105. }
  106. /**
  107. * Create a data match callable.
  108. *
  109. * Only keys/values defined in `data` are checked.
  110. */
  111. static matchData(data: Record<string, any>): MatchCallable {
  112. const dataMatcher: MatchCallable = (_url, options) => {
  113. return compareRecord(data, options.data ?? {});
  114. };
  115. return dataMatcher;
  116. }
  117. // Returns a jest mock that represents Client.request calls
  118. static addMockResponse(response: Partial<ResponseType>) {
  119. const mock = jest.fn();
  120. Client.mockResponses.unshift([
  121. {
  122. host: '',
  123. url: '',
  124. status: 200,
  125. statusCode: 200,
  126. statusText: 'OK',
  127. responseText: '',
  128. responseJSON: '',
  129. body: '',
  130. method: 'GET',
  131. callCount: 0,
  132. match: [],
  133. ...response,
  134. asyncDelay: response.asyncDelay ?? Client.asyncDelay,
  135. headers: response.headers ?? {},
  136. getResponseHeader: (key: string) => response.headers?.[key] ?? null,
  137. },
  138. mock,
  139. ]);
  140. return mock;
  141. }
  142. static findMockResponse(url: string, options: Readonly<ApiNamespace.RequestOptions>) {
  143. return Client.mockResponses.find(([response]) => {
  144. if (response.host && (options.host || '') !== response.host) {
  145. return false;
  146. }
  147. if (url !== response.url) {
  148. return false;
  149. }
  150. if ((options.method || 'GET') !== response.method) {
  151. return false;
  152. }
  153. return response.match.every(matcher => matcher(url, options));
  154. });
  155. }
  156. uniqueId() {
  157. return '123';
  158. }
  159. /**
  160. * In the real client, this clears in-flight responses. It's NOT
  161. * clearMockResponses. You probably don't want to call this from a test.
  162. */
  163. clear() {
  164. Object.values(this.activeRequests).forEach(r => r.cancel());
  165. }
  166. wrapCallback<T extends any[]>(
  167. _id: string,
  168. func: FunctionCallback<T> | undefined,
  169. _cleanup = false
  170. ) {
  171. const asyncDelay = Client.asyncDelay;
  172. return (...args: T) => {
  173. if ((RealApi.hasProjectBeenRenamed as any)(...args)) {
  174. return;
  175. }
  176. respond(asyncDelay, func, ...args);
  177. };
  178. }
  179. requestPromise(
  180. path: string,
  181. {
  182. includeAllArgs,
  183. ...options
  184. }: {includeAllArgs?: boolean} & Readonly<ApiNamespace.RequestOptions> = {}
  185. ): any {
  186. return new Promise((resolve, reject) => {
  187. this.request(path, {
  188. ...options,
  189. success: (data, ...args) => {
  190. resolve(includeAllArgs ? [data, ...args] : data);
  191. },
  192. error: (error, ..._args) => {
  193. reject(error);
  194. },
  195. });
  196. });
  197. }
  198. static errors: Record<string, Error> = {};
  199. // XXX(ts): We type the return type for requestPromise and request as `any`. Typically these woul
  200. request(url: string, options: Readonly<ApiNamespace.RequestOptions> = {}): any {
  201. const [response, mock] = Client.findMockResponse(url, options) || [
  202. undefined,
  203. undefined,
  204. ];
  205. if (!response || !mock) {
  206. const methodAndUrl = `${options.method || 'GET'} ${url}`;
  207. // Endpoints need to be mocked
  208. const err = new Error(`No mocked response found for request: ${methodAndUrl}`);
  209. // Mutate stack to drop frames since test file so that we know where in the test
  210. // this needs to be mocked
  211. const lines = err.stack?.split('\n');
  212. const startIndex = lines?.findIndex(line => line.includes('.spec.'));
  213. err.stack = ['\n', lines?.[0], ...(lines?.slice(startIndex) ?? [])].join('\n');
  214. // Throwing an error here does not do what we want it to do....
  215. // Because we are mocking an API client, we generally catch errors to show
  216. // user-friendly error messages, this means in tests this error gets gobbled
  217. // up and developer frustration ensues.
  218. // We track the errors on a static member and warn afterEach test.
  219. Client.errors[methodAndUrl] = err;
  220. } else {
  221. // has mocked response
  222. // mock gets returned when we add a mock response, will represent calls to api.request
  223. mock(url, options);
  224. const body =
  225. typeof response.body === 'function' ? response.body(url, options) : response.body;
  226. if (response.statusCode >= 300) {
  227. response.callCount++;
  228. const errorResponse = Object.assign(
  229. new RequestError(options.method || 'GET', url, new Error('Request failed')),
  230. {
  231. status: response.statusCode,
  232. responseText: JSON.stringify(body),
  233. responseJSON: body,
  234. },
  235. {
  236. overrideMimeType: () => {},
  237. abort: () => {},
  238. then: () => {},
  239. error: () => {},
  240. },
  241. new XMLHttpRequest()
  242. );
  243. this.handleRequestError(
  244. {
  245. id: '1234',
  246. path: url,
  247. requestOptions: options,
  248. },
  249. errorResponse as any,
  250. 'error',
  251. 'error'
  252. );
  253. } else {
  254. response.callCount++;
  255. respond(
  256. response.asyncDelay,
  257. options.success,
  258. body,
  259. {},
  260. {
  261. getResponseHeader: (key: string) => response.headers[key],
  262. statusCode: response.statusCode,
  263. status: response.statusCode,
  264. }
  265. );
  266. }
  267. }
  268. respond(response?.asyncDelay, options.complete);
  269. }
  270. handleRequestError = RealApi.Client.prototype.handleRequestError;
  271. }
  272. export {Client};