]> BookStack Code Mirror - bookstack/blob - resources/js/services/http.js
File Uploads: Added basic validation response formatting
[bookstack] / resources / js / services / http.js
1 /**
2  * @typedef FormattedResponse
3  * @property {Headers} headers
4  * @property {Response} original
5  * @property {Object|String} data
6  * @property {Boolean} redirected
7  * @property {Number} status
8  * @property {string} statusText
9  * @property {string} url
10  */
11
12 /**
13  * Get the content from a fetch response.
14  * Checks the content-type header to determine the format.
15  * @param {Response} response
16  * @returns {Promise<Object|String>}
17  */
18 async function getResponseContent(response) {
19     if (response.status === 204) {
20         return null;
21     }
22
23     const responseContentType = response.headers.get('Content-Type') || '';
24     const subType = responseContentType.split(';')[0].split('/').pop();
25
26     if (subType === 'javascript' || subType === 'json') {
27         return response.json();
28     }
29
30     return response.text();
31 }
32
33 export class HttpError extends Error {
34
35     constructor(response, content) {
36         super(response.statusText);
37         this.data = content;
38         this.headers = response.headers;
39         this.redirected = response.redirected;
40         this.status = response.status;
41         this.statusText = response.statusText;
42         this.url = response.url;
43         this.original = response;
44     }
45
46 }
47
48 /**
49  * @param {String} method
50  * @param {String} url
51  * @param {Object} events
52  * @return {XMLHttpRequest}
53  */
54 export function createXMLHttpRequest(method, url, events = {}) {
55     const csrfToken = document.querySelector('meta[name=token]').getAttribute('content');
56     const req = new XMLHttpRequest();
57
58     for (const [eventName, callback] of Object.entries(events)) {
59         req.addEventListener(eventName, callback.bind(req));
60     }
61
62     req.open(method, url);
63     req.withCredentials = true;
64     req.setRequestHeader('X-CSRF-TOKEN', csrfToken);
65
66     return req;
67 }
68
69 /**
70  * Create a new HTTP request, setting the required CSRF information
71  * to communicate with the back-end. Parses & formats the response.
72  * @param {String} url
73  * @param {Object} options
74  * @returns {Promise<FormattedResponse>}
75  */
76 async function request(url, options = {}) {
77     let requestUrl = url;
78
79     if (!requestUrl.startsWith('http')) {
80         requestUrl = window.baseUrl(requestUrl);
81     }
82
83     if (options.params) {
84         const urlObj = new URL(requestUrl);
85         for (const paramName of Object.keys(options.params)) {
86             const value = options.params[paramName];
87             if (typeof value !== 'undefined' && value !== null) {
88                 urlObj.searchParams.set(paramName, value);
89             }
90         }
91         requestUrl = urlObj.toString();
92     }
93
94     const csrfToken = document.querySelector('meta[name=token]').getAttribute('content');
95     const requestOptions = {...options, credentials: 'same-origin'};
96     requestOptions.headers = {
97         ...requestOptions.headers || {},
98         baseURL: window.baseUrl(''),
99         'X-CSRF-TOKEN': csrfToken,
100     };
101
102     const response = await fetch(requestUrl, requestOptions);
103     const content = await getResponseContent(response);
104     const returnData = {
105         data: content,
106         headers: response.headers,
107         redirected: response.redirected,
108         status: response.status,
109         statusText: response.statusText,
110         url: response.url,
111         original: response,
112     };
113
114     if (!response.ok) {
115         throw new HttpError(response, content);
116     }
117
118     return returnData;
119 }
120
121 /**
122  * Perform a HTTP request to the back-end that includes data in the body.
123  * Parses the body to JSON if an object, setting the correct headers.
124  * @param {String} method
125  * @param {String} url
126  * @param {Object} data
127  * @returns {Promise<FormattedResponse>}
128  */
129 async function dataRequest(method, url, data = null) {
130     const options = {
131         method,
132         body: data,
133     };
134
135     // Send data as JSON if a plain object
136     if (typeof data === 'object' && !(data instanceof FormData)) {
137         options.headers = {
138             'Content-Type': 'application/json',
139             'X-Requested-With': 'XMLHttpRequest',
140         };
141         options.body = JSON.stringify(data);
142     }
143
144     // Ensure FormData instances are sent over POST
145     // Since Laravel does not read multipart/form-data from other types
146     // of request. Hence the addition of the magic _method value.
147     if (data instanceof FormData && method !== 'post') {
148         data.append('_method', method);
149         options.method = 'post';
150     }
151
152     return request(url, options);
153 }
154
155 /**
156  * Perform a HTTP GET request.
157  * Can easily pass query parameters as the second parameter.
158  * @param {String} url
159  * @param {Object} params
160  * @returns {Promise<FormattedResponse>}
161  */
162 export async function get(url, params = {}) {
163     return request(url, {
164         method: 'GET',
165         params,
166     });
167 }
168
169 /**
170  * Perform a HTTP POST request.
171  * @param {String} url
172  * @param {Object} data
173  * @returns {Promise<FormattedResponse>}
174  */
175 export async function post(url, data = null) {
176     return dataRequest('POST', url, data);
177 }
178
179 /**
180  * Perform a HTTP PUT request.
181  * @param {String} url
182  * @param {Object} data
183  * @returns {Promise<FormattedResponse>}
184  */
185 export async function put(url, data = null) {
186     return dataRequest('PUT', url, data);
187 }
188
189 /**
190  * Perform a HTTP PATCH request.
191  * @param {String} url
192  * @param {Object} data
193  * @returns {Promise<FormattedResponse>}
194  */
195 export async function patch(url, data = null) {
196     return dataRequest('PATCH', url, data);
197 }
198
199 /**
200  * Perform a HTTP DELETE request.
201  * @param {String} url
202  * @param {Object} data
203  * @returns {Promise<FormattedResponse>}
204  */
205 async function performDelete(url, data = null) {
206     return dataRequest('DELETE', url, data);
207 }
208
209 export {performDelete as delete};
210
211 /**
212  * Parse the response text for an error response to a user
213  * presentable string. Handles a range of errors responses including
214  * validation responses & server response text.
215  * @param {String} text
216  * @returns {String}
217  */
218 export function formatErrorResponseText(text) {
219     const data = text.startsWith('{') ? JSON.parse(text) : {message: text};
220     if (!data) {
221         return text;
222     }
223
224     if (data.message || data.error) {
225         return data.message || data.error;
226     }
227
228     const values = Object.values(data);
229     const isValidation = values.every(val => {
230         return Array.isArray(val) || val.every(x => typeof x === 'string');
231     });
232
233     if (isValidation) {
234         return values.flat().join(' ');
235     }
236
237     return text;
238 }