BookStack/resources/js/services/http.ts
2024-07-18 15:13:14 +01:00

222 lines
6.8 KiB
TypeScript

type ResponseData = Record<any, any>|string;
type RequestOptions = {
params?: Record<string, string>,
headers?: Record<string, string>
};
type FormattedResponse = {
headers: Headers;
original: Response;
data: ResponseData;
redirected: boolean;
status: number;
statusText: string;
url: string;
};
export class HttpError extends Error implements FormattedResponse {
data: ResponseData;
headers: Headers;
original: Response;
redirected: boolean;
status: number;
statusText: string;
url: string;
constructor(response: Response, content: ResponseData) {
super(response.statusText);
this.data = content;
this.headers = response.headers;
this.redirected = response.redirected;
this.status = response.status;
this.statusText = response.statusText;
this.url = response.url;
this.original = response;
}
}
export class HttpManager {
/**
* Get the content from a fetch response.
* Checks the content-type header to determine the format.
*/
protected async getResponseContent(response: Response): Promise<ResponseData|null> {
if (response.status === 204) {
return null;
}
const responseContentType = response.headers.get('Content-Type') || '';
const subType = responseContentType.split(';')[0].split('/').pop();
if (subType === 'javascript' || subType === 'json') {
return response.json();
}
return response.text();
}
createXMLHttpRequest(method: string, url: string, events: Record<string, (e: Event) => void> = {}): XMLHttpRequest {
const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content');
const req = new XMLHttpRequest();
for (const [eventName, callback] of Object.entries(events)) {
req.addEventListener(eventName, callback.bind(req));
}
req.open(method, url);
req.withCredentials = true;
req.setRequestHeader('X-CSRF-TOKEN', csrfToken || '');
return req;
}
/**
* Create a new HTTP request, setting the required CSRF information
* to communicate with the back-end. Parses & formats the response.
*/
protected async request(url: string, options: RequestOptions & RequestInit = {}): Promise<FormattedResponse> {
let requestUrl = url;
if (!requestUrl.startsWith('http')) {
requestUrl = window.baseUrl(requestUrl);
}
if (options.params) {
const urlObj = new URL(requestUrl);
for (const paramName of Object.keys(options.params)) {
const value = options.params[paramName];
if (typeof value !== 'undefined' && value !== null) {
urlObj.searchParams.set(paramName, value);
}
}
requestUrl = urlObj.toString();
}
const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content') || '';
const requestOptions: RequestInit = {...options, credentials: 'same-origin'};
requestOptions.headers = {
...requestOptions.headers || {},
baseURL: window.baseUrl(''),
'X-CSRF-TOKEN': csrfToken,
};
const response = await fetch(requestUrl, requestOptions);
const content = await this.getResponseContent(response) || '';
const returnData: FormattedResponse = {
data: content,
headers: response.headers,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
url: response.url,
original: response,
};
if (!response.ok) {
throw new HttpError(response, content);
}
return returnData;
}
/**
* Perform a HTTP request to the back-end that includes data in the body.
* Parses the body to JSON if an object, setting the correct headers.
*/
protected async dataRequest(method: string, url: string, data: Record<string, any>|null): Promise<FormattedResponse> {
const options: RequestInit & RequestOptions = {
method,
body: data as BodyInit,
};
// Send data as JSON if a plain object
if (typeof data === 'object' && !(data instanceof FormData)) {
options.headers = {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
};
options.body = JSON.stringify(data);
}
// Ensure FormData instances are sent over POST
// Since Laravel does not read multipart/form-data from other types
// of request, hence the addition of the magic _method value.
if (data instanceof FormData && method !== 'post') {
data.append('_method', method);
options.method = 'post';
}
return this.request(url, options);
}
/**
* Perform a HTTP GET request.
* Can easily pass query parameters as the second parameter.
*/
async get(url: string, params: {} = {}): Promise<FormattedResponse> {
return this.request(url, {
method: 'GET',
params,
});
}
/**
* Perform a HTTP POST request.
*/
async post(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
return this.dataRequest('POST', url, data);
}
/**
* Perform a HTTP PUT request.
*/
async put(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
return this.dataRequest('PUT', url, data);
}
/**
* Perform a HTTP PATCH request.
*/
async patch(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
return this.dataRequest('PATCH', url, data);
}
/**
* Perform a HTTP DELETE request.
*/
async delete(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
return this.dataRequest('DELETE', url, data);
}
/**
* Parse the response text for an error response to a user
* presentable string. Handles a range of errors responses including
* validation responses & server response text.
*/
protected formatErrorResponseText(text: string): string {
const data = text.startsWith('{') ? JSON.parse(text) : {message: text};
if (!data) {
return text;
}
if (data.message || data.error) {
return data.message || data.error;
}
const values = Object.values(data);
const isValidation = values.every(val => {
return Array.isArray(val) && val.every(x => typeof x === 'string');
});
if (isValidation) {
return values.flat().join(' ');
}
return text;
}
}