]> BookStack Code Mirror - bookstack/blob - resources/js/services/http.ts
Merge pull request #5626 from BookStackApp/rubentalstra-development
[bookstack] / resources / js / services / http.ts
1 type ResponseData = Record<any, any>|string;
2
3 type RequestOptions = {
4     params?: Record<string, string>,
5     headers?: Record<string, string>
6 };
7
8 type FormattedResponse = {
9     headers: Headers;
10     original: Response;
11     data: ResponseData;
12     redirected: boolean;
13     status: number;
14     statusText: string;
15     url: string;
16 };
17
18 export class HttpError extends Error implements FormattedResponse {
19
20     data: ResponseData;
21     headers: Headers;
22     original: Response;
23     redirected: boolean;
24     status: number;
25     statusText: string;
26     url: string;
27
28     constructor(response: Response, content: ResponseData) {
29         super(response.statusText);
30         this.data = content;
31         this.headers = response.headers;
32         this.redirected = response.redirected;
33         this.status = response.status;
34         this.statusText = response.statusText;
35         this.url = response.url;
36         this.original = response;
37     }
38 }
39
40 export class HttpManager {
41
42     /**
43      * Get the content from a fetch response.
44      * Checks the content-type header to determine the format.
45      */
46     protected async getResponseContent(response: Response): Promise<ResponseData|null> {
47         if (response.status === 204) {
48             return null;
49         }
50
51         const responseContentType = response.headers.get('Content-Type') || '';
52         const subType = responseContentType.split(';')[0].split('/').pop();
53
54         if (subType === 'javascript' || subType === 'json') {
55             return response.json();
56         }
57
58         return response.text();
59     }
60
61     createXMLHttpRequest(method: string, url: string, events: Record<string, (e: Event) => void> = {}): XMLHttpRequest {
62         const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content');
63         const req = new XMLHttpRequest();
64
65         for (const [eventName, callback] of Object.entries(events)) {
66             req.addEventListener(eventName, callback.bind(req));
67         }
68
69         req.open(method, url);
70         req.withCredentials = true;
71         req.setRequestHeader('X-CSRF-TOKEN', csrfToken || '');
72
73         return req;
74     }
75
76     /**
77      * Create a new HTTP request, setting the required CSRF information
78      * to communicate with the back-end. Parses & formats the response.
79      */
80     protected async request(url: string, options: RequestOptions & RequestInit = {}): Promise<FormattedResponse> {
81         let requestUrl = url;
82
83         if (!requestUrl.startsWith('http')) {
84             requestUrl = window.baseUrl(requestUrl);
85         }
86
87         if (options.params) {
88             const urlObj = new URL(requestUrl);
89             for (const paramName of Object.keys(options.params)) {
90                 const value = options.params[paramName];
91                 if (typeof value !== 'undefined' && value !== null) {
92                     urlObj.searchParams.set(paramName, value);
93                 }
94             }
95             requestUrl = urlObj.toString();
96         }
97
98         const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content') || '';
99         const requestOptions: RequestInit = {...options, credentials: 'same-origin'};
100         requestOptions.headers = {
101             ...requestOptions.headers || {},
102             baseURL: window.baseUrl(''),
103             'X-CSRF-TOKEN': csrfToken,
104         };
105
106         const response = await fetch(requestUrl, requestOptions);
107         const content = await this.getResponseContent(response) || '';
108         const returnData: FormattedResponse = {
109             data: content,
110             headers: response.headers,
111             redirected: response.redirected,
112             status: response.status,
113             statusText: response.statusText,
114             url: response.url,
115             original: response,
116         };
117
118         if (!response.ok) {
119             throw new HttpError(response, content);
120         }
121
122         return returnData;
123     }
124
125     /**
126      * Perform a HTTP request to the back-end that includes data in the body.
127      * Parses the body to JSON if an object, setting the correct headers.
128      */
129     protected async dataRequest(method: string, url: string, data: Record<string, any>|null): Promise<FormattedResponse> {
130         const options: RequestInit & RequestOptions = {
131             method,
132             body: data as BodyInit,
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 this.request(url, options);
153     }
154
155     /**
156      * Perform a HTTP GET request.
157      * Can easily pass query parameters as the second parameter.
158      */
159     async get(url: string, params: {} = {}): Promise<FormattedResponse> {
160         return this.request(url, {
161             method: 'GET',
162             params,
163         });
164     }
165
166     /**
167      * Perform a HTTP POST request.
168      */
169     async post(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
170         return this.dataRequest('POST', url, data);
171     }
172
173     /**
174      * Perform a HTTP PUT request.
175      */
176     async put(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
177         return this.dataRequest('PUT', url, data);
178     }
179
180     /**
181      * Perform a HTTP PATCH request.
182      */
183     async patch(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
184         return this.dataRequest('PATCH', url, data);
185     }
186
187     /**
188      * Perform a HTTP DELETE request.
189      */
190     async delete(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
191         return this.dataRequest('DELETE', url, data);
192     }
193
194     /**
195      * Parse the response text for an error response to a user
196      * presentable string. Handles a range of errors responses including
197      * validation responses & server response text.
198      */
199     protected formatErrorResponseText(text: string): string {
200         const data = text.startsWith('{') ? JSON.parse(text) : {message: text};
201         if (!data) {
202             return text;
203         }
204
205         if (data.message || data.error) {
206             return data.message || data.error;
207         }
208
209         const values = Object.values(data);
210         const isValidation = values.every(val => {
211             return Array.isArray(val) && val.every(x => typeof x === 'string');
212         });
213
214         if (isValidation) {
215             return values.flat().join(' ');
216         }
217
218         return text;
219     }
220
221 }
Morty Proxy This is a proxified and sanitized view of the page, visit original site.