gristlabs_grist-core/app/common/BaseAPI.ts

162 lines
6.0 KiB
TypeScript
Raw Normal View History

import {ApiError, ApiErrorDetails} from 'app/common/ApiError';
import axios, {AxiosRequestConfig, AxiosResponse} from 'axios';
import {tbind} from './tbind';
export interface IOptions {
headers?: Record<string, string>;
fetch?: typeof fetch;
newFormData?: () => FormData; // constructor for FormData depends on platform.
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 response 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);
}
// Make a request to the given URL, and read the response. Handles errors, and
// counts pending requests in the same way as BaseAPI methods do.
public static request(url: string, init: RequestInit = {}): Promise<Response> {
return new BaseAPI().request(url, init);
}
private static _numPendingRequests: number = 0;
protected fetch: typeof fetch;
protected newFormData: () => FormData;
private _headers: Record<string, string>;
private _extraParameters?: Map<string, string>;
constructor(options: IOptions = {}) {
this.fetch = options.fetch || tbind(window.fetch, window);
this.newFormData = options.newFormData || (() => new FormData());
this._headers = {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...options.headers
};
// If we are in the client, and have a boot key query parameter,
// pass it on as a header to make it available for authentication.
// This is a fallback mechanism if auth is broken to access the
// admin panel.
// TODO: should this be more selective?
if (typeof window !== 'undefined' && window.location &&
window.location.pathname.endsWith('/admin')) {
const bootKey = new URLSearchParams(window.location.search).get('boot-key');
if (bootKey) {
this._headers['X-Boot-Key'] = bootKey;
}
}
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);
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 = {}; }
(core) Adds a UI panel for managing webhooks Summary: This adds a UI panel for managing webhooks. Work started by Cyprien Pindat. You can find the UI on a document's settings page. Main changes relative to Cyprien's demo: * Changed behavior of virtual table to be more consistent with the rest of Grist, by factoring out part of the implementation of on-demand tables. * Cell values that would create an error can now be denied and reverted (as for the rest of Grist). * Changes made by other users are integrated in a sane way. * Basic undo/redo support is added using the regular undo/redo stack. * The table list in the drop-down is now updated if schema changes. * Added a notification from back-end when webhook status is updated so constant polling isn't needed to support multi-user operation. * Factored out webhook specific logic from general virtual table support. * Made a bunch of fixes to various broken behavior. * Added tests. The code remains somewhat unpolished, and behavior in the presence of errors is imperfect in general but may be adequate for this case. I assume that we'll soon be lifting the restriction on the set of domains that are supported for webhooks - otherwise we'd want to provide some friendly way to discover that list of supported domains rather than just throwing an error. I don't actually know a lot about how the front-end works - it looks like tables/columns/fields/sections can be safely added if they have string ids that won't collide with bone fide numeric ids from the back end. Sneaky. Contains a migration, so needs an extra reviewer for that. Test Plan: added tests Reviewers: jarek, dsagal Reviewed By: jarek, dsagal Differential Revision: https://phab.getgrist.com/D3856
2023-05-08 22:06:24 +00:00
const details: ApiErrorDetails = body.details && typeof body.details === 'object' ? body.details :
{errorDetails: body.details};
// If a userError is already specified, do not overwrite it.
// (The error handling here is quite confusing, would it not be better
// to just unserialize an ApiError into the form it would have had on
// the server?)
if (body.error && !details.userError) {
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);
}