blob: 2da1fb870884d8afead26d031c58393eabf8a0a3 [file] [log] [blame]
/**
* @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);
}
}