mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
5dc4706dc7
This adds some remaining parts of the boot page to the admin panel, and then removes the boot page.
162 lines
6.0 KiB
TypeScript
162 lines
6.0 KiB
TypeScript
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 = {}; }
|
|
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);
|
|
}
|