diff --git a/resources/js/app.js b/resources/js/app.js index 812a451f2..e08b90ba1 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,5 @@ import {EventManager} from './services/events.ts'; -import * as httpInstance from './services/http'; +import {HttpManager} from './services/http.ts'; import Translations from './services/translations'; import * as componentMap from './components'; import {ComponentStore} from './services/components.ts'; @@ -20,7 +20,7 @@ window.importVersioned = function importVersioned(moduleName) { }; // Set events and http services on window -window.$http = httpInstance; +window.$http = new HttpManager(); window.$events = new EventManager(); // Translation setup diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts index a9b9275e9..1f216b7a5 100644 --- a/resources/js/global.d.ts +++ b/resources/js/global.d.ts @@ -1,9 +1,12 @@ import {ComponentStore} from "./services/components"; import {EventManager} from "./services/events"; +import {HttpManager} from "./services/http"; declare global { interface Window { $components: ComponentStore, $events: EventManager, + $http: HttpManager, + baseUrl: (path: string) => string; } } \ No newline at end of file diff --git a/resources/js/services/drawio.ts b/resources/js/services/drawio.ts index 75b161f75..c0a6b5044 100644 --- a/resources/js/services/drawio.ts +++ b/resources/js/services/drawio.ts @@ -1,6 +1,7 @@ // Docs: https://www.diagrams.net/doc/faq/embed-mode import * as store from './store'; import {ConfirmDialog} from "../components"; +import {HttpError} from "./http"; type DrawioExportEventResponse = { action: 'export', @@ -145,9 +146,10 @@ export function close() { export async function load(drawingId: string): Promise { try { const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`)); - return `data:image/png;base64,${resp.data.content}`; + const data = resp.data as {content: string}; + return `data:image/png;base64,${data.content}`; } catch (error) { - if (error instanceof window.$http.HttpError) { + if (error instanceof HttpError) { window.$events.showResponseError(error); } close(); diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts index c251ee21b..7d72a9f1a 100644 --- a/resources/js/services/events.ts +++ b/resources/js/services/events.ts @@ -1,3 +1,5 @@ +import {HttpError} from "./http"; + export class EventManager { protected listeners: Record void)[]> = {}; protected stack: {name: string, data: {}}[] = []; @@ -62,9 +64,9 @@ export class EventManager { /** * Notify standard server-provided error messages. */ - showResponseError(responseErr: {status?: number, data?: {message?: string}}): void { + showResponseError(responseErr: {status?: number, data?: Record}|HttpError): void { if (!responseErr.status) return; - if (responseErr.status >= 400 && responseErr.data && responseErr.data.message) { + if (responseErr.status >= 400 && typeof responseErr.data === 'object' && responseErr.data.message) { this.error(responseErr.data.message); } } diff --git a/resources/js/services/http.js b/resources/js/services/http.js deleted file mode 100644 index d95e4a59a..000000000 --- a/resources/js/services/http.js +++ /dev/null @@ -1,238 +0,0 @@ -/** - * @typedef FormattedResponse - * @property {Headers} headers - * @property {Response} original - * @property {Object|String} data - * @property {Boolean} redirected - * @property {Number} status - * @property {string} statusText - * @property {string} url - */ - -/** - * Get the content from a fetch response. - * Checks the content-type header to determine the format. - * @param {Response} response - * @returns {Promise} - */ -async function getResponseContent(response) { - 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(); -} - -export class HttpError extends Error { - - constructor(response, content) { - 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; - } - -} - -/** - * @param {String} method - * @param {String} url - * @param {Object} events - * @return {XMLHttpRequest} - */ -export function createXMLHttpRequest(method, url, events = {}) { - 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. - * @param {String} url - * @param {Object} options - * @returns {Promise} - */ -async function request(url, options = {}) { - 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 = {...options, credentials: 'same-origin'}; - requestOptions.headers = { - ...requestOptions.headers || {}, - baseURL: window.baseUrl(''), - 'X-CSRF-TOKEN': csrfToken, - }; - - const response = await fetch(requestUrl, requestOptions); - const content = await getResponseContent(response); - const returnData = { - 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. - * @param {String} method - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -async function dataRequest(method, url, data = null) { - const options = { - method, - body: data, - }; - - // 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 request(url, options); -} - -/** - * Perform a HTTP GET request. - * Can easily pass query parameters as the second parameter. - * @param {String} url - * @param {Object} params - * @returns {Promise} - */ -export async function get(url, params = {}) { - return request(url, { - method: 'GET', - params, - }); -} - -/** - * Perform a HTTP POST request. - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -export async function post(url, data = null) { - return dataRequest('POST', url, data); -} - -/** - * Perform a HTTP PUT request. - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -export async function put(url, data = null) { - return dataRequest('PUT', url, data); -} - -/** - * Perform a HTTP PATCH request. - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -export async function patch(url, data = null) { - return dataRequest('PATCH', url, data); -} - -/** - * Perform a HTTP DELETE request. - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -async function performDelete(url, data = null) { - return dataRequest('DELETE', url, data); -} - -export {performDelete as delete}; - -/** - * 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. - * @param {String} text - * @returns {String} - */ -export function formatErrorResponseText(text) { - 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; -} diff --git a/resources/js/services/http.ts b/resources/js/services/http.ts new file mode 100644 index 000000000..f9eaafc39 --- /dev/null +++ b/resources/js/services/http.ts @@ -0,0 +1,221 @@ +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; + } + +}