| /** |
| * @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) { |
| // Wrap the error to get more information about the stack. |
| const newErr = new Error( |
| `Network error when trying to fetch. Cause: ${(err as Error).message}` |
| ); |
| newErr.stack = (newErr.stack ?? '') + '\n' + ((err as Error).stack ?? ''); |
| if (req.errFn) { |
| await req.errFn.call(undefined, null, newErr); |
| } else { |
| fireNetworkError(newErr); |
| } |
| throw newErr; |
| } |
| 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); |
| } |
| } |