gristlabs_grist-core/app/common/BaseAPI.ts
Paul Fitzpatrick 6af811f7ab (core) give more detailed reasons for access denied when memos are present
Summary:
With this change, if a comment is added to an ACL formula, then that comment will be offered to the user if access is denied and that rule could potentially have granted access.

The code is factored so that when access is permitted, or when partially visible tables are being filtered, there is little overhead. Comments are gathered only when an explicit denial of access.

Test Plan: added tests, updated tests

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2730
2021-02-15 17:02:24 -05:00

145 lines
5.1 KiB
TypeScript

import {ApiError, ApiErrorDetails} from 'app/common/ApiError';
import axios, {AxiosRequestConfig, AxiosResponse} from 'axios';
import {tbind} from './tbind';
export type ILogger = Pick<Console, 'log'|'debug'|'info'|'warn'|'error'>;
export interface IOptions {
headers?: Record<string, string>;
fetch?: typeof fetch;
newFormData?: () => FormData; // constructor for FormData depends on platform.
logger?: ILogger;
extraParameters?: Map<string, string>; // if set, add query parameters to requests.
}
/**
* Base setup class for creating a REST API client interface.
*/
export class BaseAPI {
// Count of pending requests. It is relied on by tests.
public static numPendingRequests(): number { return this._numPendingRequests; }
// Wrap a promise to add to the count of pending requests until the promise is resolved.
public static async countPendingRequest<T>(promise: Promise<T>): Promise<T> {
try {
BaseAPI._numPendingRequests++;
return await promise;
} finally {
BaseAPI._numPendingRequests--;
}
}
// Define a decorator for methods in BaseAPI or derived classes.
public static countRequest(target: unknown, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function(...args: any[]) {
return BaseAPI.countPendingRequest(originalMethod.apply(this, args));
};
}
// Make a JSON request to the given URL, and read the esponse as JSON. Handles errors, and
// counts pending requests in the same way as BaseAPI methods do.
public static requestJson(url: string, init: RequestInit = {}): Promise<unknown> {
return new BaseAPI().requestJson(url, init);
}
private static _numPendingRequests: number = 0;
protected fetch: typeof fetch;
protected newFormData: () => FormData;
private _headers: Record<string, string>;
private _logger: ILogger;
private _extraParameters?: Map<string, string>;
constructor(options: IOptions = {}) {
this.fetch = options.fetch || tbind(window.fetch, window);
this.newFormData = options.newFormData || (() => new FormData());
this._logger = options.logger || console;
this._headers = {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...options.headers
};
this._extraParameters = options.extraParameters;
}
// Make a modified request, exposed for test convenience.
public async testRequest(url: string, init: RequestInit = {}): Promise<Response> {
return this.request(url, init);
}
public defaultHeaders() {
return this._headers;
}
public defaultHeadersWithoutContentType() {
const headers = {...this.defaultHeaders()};
delete headers['Content-Type'];
return headers;
}
// Similar to request, but uses the axios library, and supports progress indicator.
@BaseAPI.countRequest
protected async requestAxios(url: string, config: AxiosRequestConfig): Promise<AxiosResponse> {
// If using with FormData in node, axios needs the headers prepared by FormData.
let headers = config.headers;
if (config.data && typeof config.data.getHeaders === 'function') {
headers = {...config.data.getHeaders(), ...headers};
}
const resp = await axios.request({
url,
withCredentials: true,
validateStatus: (status) => true, // This is more like fetch
...config,
headers,
});
if (resp.status !== 200) {
throwApiError(url, resp, resp.data);
}
return resp;
}
@BaseAPI.countRequest
protected async request(input: string, init: RequestInit = {}): Promise<Response> {
init = Object.assign({ headers: this._headers, credentials: 'include' }, init);
if (this._extraParameters) {
const url = new URL(input);
for (const [key, val] of this._extraParameters.entries()) {
url.searchParams.set(key, val);
input = url.href;
}
}
const resp = await this.fetch(input, init);
this._logger.log("Fetched", input);
if (resp.status !== 200) {
const body = await resp.json().catch(() => ({}));
throwApiError(input, resp, body);
}
return resp;
}
/**
* Make a request, and read the response as JSON. This allows counting the request as pending
* until it has been read, which is relied on by tests.
*/
@BaseAPI.countRequest
protected async requestJson(input: string, init: RequestInit = {}): Promise<any> {
return (await this.request(input, init)).json();
}
}
function throwApiError(url: string, resp: Response | AxiosResponse, body: any) {
// If the response includes details, include them into the ApiError we construct. Include
// also the error message from the server as details.userError. It's used by the Notifier.
if (!body) { body = {}; }
const details: ApiErrorDetails = body.details && typeof body.details === 'object' ? body.details : {};
if (body.error) {
details.userError = body.error;
}
if (body.memos) {
details.memos = body.memos;
}
throw new ApiError(`Request to ${url} failed with status ${resp.status}: ` +
`${resp.statusText} (${body.error || 'unknown cause'})`, resp.status, details);
}