type ResponseData = Record|string; type RequestOptions = { params?: Record, headers?: Record }; 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 { 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 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 { 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|null): Promise { 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 { return this.request(url, { method: 'GET', params, }); } /** * Perform a HTTP POST request. */ async post(url: string, data: null|Record = null): Promise { return this.dataRequest('POST', url, data); } /** * Perform a HTTP PUT request. */ async put(url: string, data: null|Record = null): Promise { return this.dataRequest('PUT', url, data); } /** * Perform a HTTP PATCH request. */ async patch(url: string, data: null|Record = null): Promise { return this.dataRequest('PATCH', url, data); } /** * Perform a HTTP DELETE request. */ async delete(url: string, data: null|Record = null): Promise { 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; } }