summaryrefslogtreecommitdiffstats
path: root/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
blob: 2da1fb870884d8afead26d031c58393eabf8a0a3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
/**
 * @license
 * Copyright 2024 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import {getBaseUrl} from '../../../../utils/url-util';
import {AuthService} from '../../../../services/gr-auth/gr-auth';
import {ParsedJSON, RequestPayload} from '../../../../types/common';
import {HttpMethod} from '../../../../constants/constants';
import {RpcLogEventDetail} from '../../../../types/events';
import {
  fire,
  fireNetworkError,
  fireServerError,
} from '../../../../utils/event-util';
import {
  AuthRequestInit,
  FetchRequest as FetchRequestBase,
} from '../../../../types/types';
import {ErrorCallback} from '../../../../api/rest';
import {Scheduler, Task} from '../../../../services/scheduler/scheduler';
import {RetryError} from '../../../../services/scheduler/retry-scheduler';

export const JSON_PREFIX = ")]}'";

export interface ResponsePayload {
  parsed: ParsedJSON;
  raw: string;
}

export async function readJSONResponsePayload(
  response: Response
): Promise<ResponsePayload> {
  const text = await response.text();
  let result: ParsedJSON;
  try {
    result = parsePrefixedJSON(text);
  } catch (_) {
    throw new Error(`Response payload is not prefixed json. Payload: ${text}`);
  }
  return {parsed: result!, raw: text};
}

export function parsePrefixedJSON(jsonWithPrefix: string): ParsedJSON {
  return JSON.parse(jsonWithPrefix.substring(JSON_PREFIX.length)) as ParsedJSON;
}

// Adds base url if not added in cache key
// or doesn't add it if it already is there.
function addBaseUrl(key: string) {
  if (!getBaseUrl()) return key;
  return key.startsWith(getBaseUrl()) ? key : getBaseUrl() + key;
}

/**
 * Wrapper around Map for caching server responses. Site-based so that
 * changes to CANONICAL_PATH will result in a different cache going into
 * effect.
 *
 * All methods operate on the cache for the current CANONICAL_PATH.
 * Accessing cache entries for older CANONICAL_PATH not supported.
 */
// TODO(kamilm): Seems redundant to have both this and FetchPromisesCache
//   consider joining their functionality into a single cache.
export class SiteBasedCache {
  private readonly data = new Map<string, Map<string, ParsedJSON>>();

  constructor() {
    if (window.INITIAL_DATA) {
      // Put all data shipped with index.html into the cache. This makes it
      // so that we spare more round trips to the server when the app loads
      // initially.
      // TODO(kamilm): This implies very strict format of what is stored in
      //   INITIAL_DATA which is not clear from the name, consider renaming.
      Object.entries(window.INITIAL_DATA).forEach(e =>
        this._cache().set(addBaseUrl(e[0]), e[1] as unknown as ParsedJSON)
      );
    }
  }

  // Returns the cache for the current canonical path.
  _cache(): Map<string, ParsedJSON> {
    if (!this.data.has(getBaseUrl())) {
      this.data.set(getBaseUrl(), new Map<string, ParsedJSON>());
    }
    return this.data.get(getBaseUrl())!;
  }

  has(key: string) {
    return this._cache().has(addBaseUrl(key));
  }

  get(key: string): ParsedJSON | undefined {
    return this._cache().get(addBaseUrl(key));
  }

  set(key: string, value: ParsedJSON) {
    this._cache().set(addBaseUrl(key), value);
  }

  delete(key: string) {
    this._cache().delete(addBaseUrl(key));
  }

  invalidatePrefix(prefix: string) {
    const newMap = new Map<string, ParsedJSON>();
    for (const [key, value] of this._cache().entries()) {
      if (!key.startsWith(addBaseUrl(prefix))) {
        newMap.set(key, value);
      }
    }
    this.data.set(getBaseUrl(), newMap);
  }
}

type FetchPromisesCacheData = {
  [url: string]: Promise<ParsedJSON | undefined> | undefined;
};

/**
 * Stores promises for inflight requests, by url.
 */
export class FetchPromisesCache {
  private data: FetchPromisesCacheData;

  constructor() {
    this.data = {};
  }

  public testOnlyGetData() {
    return this.data;
  }

  /**
   * @return true only if a value for a key sets and it is not undefined
   */
  has(key: string): boolean {
    return !!this.data[addBaseUrl(key)];
  }

  get(key: string) {
    return this.data[addBaseUrl(key)];
  }

  /**
   * @param value a Promise to store in the cache. Pass undefined value to
   *     mark key as deleted.
   */
  set(key: string, value: Promise<ParsedJSON | undefined> | undefined) {
    this.data[addBaseUrl(key)] = value;
  }

  invalidatePrefix(prefix: string) {
    const newData: FetchPromisesCacheData = {};
    Object.entries(this.data).forEach(([key, value]) => {
      if (!key.startsWith(addBaseUrl(prefix))) {
        newData[key] = value;
      }
    });
    this.data = newData;
  }
}

export type FetchParams = {
  [name: string]: string[] | string | number | boolean | undefined | null;
};

/**
 * Error callback that throws an error.
 *
 * Pass into REST API methods as errFn to make the returned Promises reject on
 * error.
 *
 * If error is provided, it's thrown.
 * Otherwise if response with error is provided the promise that will throw an
 * error is returned.
 */
export function throwingErrorCallback(
  response?: Response | null,
  err?: Error
): void | Promise<void> {
  if (err) throw err;
  if (!response) return;

  return response.text().then(errorText => {
    let message = `Error ${response.status}`;
    if (response.statusText) {
      message += ` (${response.statusText})`;
    }
    if (errorText) {
      message += `: ${errorText}`;
    }
    throw new Error(message);
  });
}

export interface FetchRequest extends FetchRequestBase {
  /**
   * If neither this or anonymizedUrl specified no 'gr-rpc-log' event is fired.
   */
  reportUrlAsIs?: boolean;
  /** Extra url params to be encoded and added to the url. */
  params?: FetchParams;
  /**
   * Callback that is called, if an error was caught during fetch or if the
   * response was returned with a non-2xx status.
   */
  errFn?: ErrorCallback;
  /**
   * If true, response with non-200 status will cause an error to be reported
   * via server-error event or errFn, if provided.
   */
  // TODO(kamilm): Consider changing the default to true. It makes more sense to
  //   only skip the check if the caller wants to prosess status themselves.
  reportServerError?: boolean;
}

export interface FetchOptionsInit {
  method?: HttpMethod;
  body?: RequestPayload;
  contentType?: string;
  headers?: Record<string, string>;
}

export function getFetchOptions(init: FetchOptionsInit): AuthRequestInit {
  const options: AuthRequestInit = {
    method: init.method,
  };
  if (init.body) {
    options.headers = new Headers();
    options.headers.set('Content-Type', init.contentType || 'application/json');
    options.body =
      typeof init.body === 'string' ? init.body : JSON.stringify(init.body);
  }
  // Copy headers after processing body, so that explicit headers can override
  // if necessary.
  if (init.headers) {
    if (!options.headers) {
      options.headers = new Headers();
    }
    for (const [name, value] of Object.entries(init.headers)) {
      options.headers.set(name, value);
    }
  }
  return options;
}

export class GrRestApiHelper {
  constructor(
    private readonly _cache: SiteBasedCache,
    private readonly _auth: AuthService,
    private readonly _fetchPromisesCache: FetchPromisesCache,
    private readonly readScheduler: Scheduler<Response>,
    private readonly writeScheduler: Scheduler<Response>
  ) {}

  private schedule(method: string, task: Task<Response>): Promise<Response> {
    if (method === 'PUT' || method === 'POST' || method === 'DELETE') {
      return this.writeScheduler.schedule(task);
    } else {
      return this.readScheduler.schedule(task);
    }
  }

  /**
   * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
   * with timing and logging.
   */
  private fetchImpl(req: FetchRequest): Promise<Response> {
    const method = req.fetchOptions?.method ?? HttpMethod.GET;
    const startTime = Date.now();
    const task = async () => {
      const res = await this._auth.fetch(req.url, req.fetchOptions);
      // Check for "too many requests" error and throw RetryError to cause a
      // retry in this case, if the scheduler attempts retries.
      if (!res.ok && res.status === 429) throw new RetryError<Response>(res);
      return res;
    };

    const resPromise = this.schedule(method, task).catch((err: unknown) => {
      if (err instanceof RetryError) {
        return err.payload;
      } else {
        throw err;
      }
    });

    // Log the call after it completes.
    resPromise.then(res => this.logCall(req, startTime, res.status));
    // Return the response directly (without the log).
    return resPromise;
  }

  /**
   * Log information about a REST call. Because the elapsed time is determined
   * by this method, it should be called immediately after the request
   * finishes.
   *
   * Private, but used in tests.
   *
   * @param startTime the time that the request was started.
   * @param status the HTTP status of the response. The status value
   *     is used here rather than the response object so there is no way this
   *     method can read the body stream.
   */
  logCall(req: FetchRequest, startTime: number, status: number) {
    const method =
      req.fetchOptions && req.fetchOptions.method
        ? req.fetchOptions.method
        : 'GET';
    const endTime = Date.now();
    const elapsed = endTime - startTime;
    const startAt = new Date(startTime);
    const endAt = new Date(endTime);
    console.debug(
      [
        'HTTP',
        status,
        method,
        `${elapsed}ms`,
        req.anonymizedUrl || req.url,
        `(${startAt.toISOString()}, ${endAt.toISOString()})`,
      ].join(' ')
    );
    if (req.anonymizedUrl) {
      const detail: RpcLogEventDetail = {
        status,
        method,
        elapsed,
        anonymizedUrl: req.anonymizedUrl,
      };
      fire(document, 'gr-rpc-log', detail);
    }
  }

  /**
   * Fetch from url provided.
   *
   * Performs auth. Validates auth expiry errors.
   * Will report any errors (by firing a corresponding event or calling errFn)
   * that happen during the request, but doesn't inspect the status of the
   * received response unless req.reportServerError = true.
   *
   * @return Promise resolves to a native Response.
   *     If an error occurs when performing a request, promise rejects.
   */
  async fetch(req: FetchRequest): Promise<Response> {
    const urlWithParams = this.urlWithParams(req.url, req.params);
    const fetchReq: FetchRequest = {
      url: urlWithParams,
      fetchOptions: req.fetchOptions,
      anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
    };
    let resp: Response;
    try {
      resp = await this.fetchImpl(fetchReq);
    } catch (err) {
      if (req.errFn) {
        await req.errFn.call(undefined, null, err as Error);
      } else {
        fireNetworkError(err as Error);
      }
      throw err;
    }
    if (req.reportServerError && !resp.ok) {
      if (req.errFn) {
        await req.errFn.call(undefined, resp);
      } else {
        fireServerError(resp, req);
      }
    }
    return resp;
  }

  /**
   * Fetch JSON from url provided.
   *
   * Returned promise rejects if an error occurs when performing a request or
   * if the response payload doesn't contain a valid prefixed JSON.
   *
   * If response status is not 2xx, promise resolves to undefined and error is
   * reported, through errFn callback or via 'sever-error' event. The error can
   * be suppressed with req.reportServerError = false.
   *
   * If JSON parsing fails the promise rejects.
   *
   * @param noAcceptHeader - don't add default accept json header
   * @return Promise that resolves to a parsed response.
   */
  async fetchJSON(
    req: FetchRequest,
    noAcceptHeader?: boolean
  ): Promise<ParsedJSON | undefined> {
    if (!noAcceptHeader) {
      req = this.addAcceptJsonHeader(req);
    }
    req.reportServerError ??= true;
    const response = await this.fetch(req);
    if (!response.ok) {
      return undefined;
    }
    // TODO(kamilm): The parsing error should likely be reported via errFn or
    // gr-error-manager as well.
    return (await readJSONResponsePayload(response)).parsed;
  }

  /**
   * Add extra url params to the url.
   *
   * Params with values (not undefined) added as <key>=<value>. If value is an
   * array a separate <key>=<value> param is added for every value.
   */
  urlWithParams(url: string, fetchParams?: FetchParams): string {
    if (!fetchParams) {
      return getBaseUrl() + url;
    }

    const params: Array<string | number | boolean> = [];
    for (const [paramKey, paramValue] of Object.entries(fetchParams)) {
      if (paramValue === null || paramValue === undefined) {
        params.push(this.encodeRFC5987(paramKey));
        continue;
      }

      if (Array.isArray(paramValue)) {
        for (const value of paramValue) {
          params.push(
            `${this.encodeRFC5987(paramKey)}=${this.encodeRFC5987(value)}`
          );
        }
      } else {
        params.push(
          `${this.encodeRFC5987(paramKey)}=${this.encodeRFC5987(paramValue)}`
        );
      }
    }
    return getBaseUrl() + url + '?' + params.join('&');
  }

  // Backend encode url in RFC5987 and frontend needs to do same to match
  // queries for preloading queries
  encodeRFC5987(uri: string | number | boolean) {
    return encodeURIComponent(uri).replace(
      /['()*]/g,
      c => '%' + c.charCodeAt(0).toString(16)
    );
  }

  addAcceptJsonHeader(req: FetchRequest) {
    if (!req.fetchOptions) req.fetchOptions = {};
    if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
    if (!req.fetchOptions.headers.has('Accept')) {
      req.fetchOptions.headers.append('Accept', 'application/json');
    }
    return req;
  }

  /**
   * Fetch JSON using cached value if available.
   *
   * If there is an in-flight request with the same url returns the promise for
   * the in-flight request. If previous call for the same url resulted in the
   * successful response it is returned. Otherwise a new request is sent.
   *
   * Only req.url with req.params is considered for the caching key;
   * headers or request body are not included in cache key.
   */
  fetchCacheJSON(req: FetchRequest): Promise<ParsedJSON | undefined> {
    const urlWithParams = this.urlWithParams(req.url, req.params);
    if (this._fetchPromisesCache.has(urlWithParams)) {
      return this._fetchPromisesCache.get(urlWithParams)!;
    }
    if (this._cache.has(urlWithParams)) {
      return Promise.resolve(this._cache.get(urlWithParams)!);
    }
    this._fetchPromisesCache.set(
      urlWithParams,
      this.fetchJSON(req)
        .then(response => {
          if (response !== undefined) {
            this._cache.set(urlWithParams, response);
          }
          this._fetchPromisesCache.set(urlWithParams, undefined);
          return response;
        })
        .catch(err => {
          this._fetchPromisesCache.set(urlWithParams, undefined);
          throw err;
        })
    );
    return this._fetchPromisesCache.get(urlWithParams)!;
  }

  invalidateFetchPromisesPrefix(prefix: string) {
    this._fetchPromisesCache.invalidatePrefix(prefix);
    this._cache.invalidatePrefix(prefix);
  }
}