diff --git a/js/src/ajax.js b/js/src/ajax.js
new file mode 100644
index 0000000..8c615fe
--- /dev/null
+++ b/js/src/ajax.js
@@ -0,0 +1,111 @@
+/*
+ * μlogger
+ *
+ * Copyright(C) 2019 Bartek Fabiszewski (www.fabiszewski.net)
+ *
+ * This is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+export default class uAjax {
+
+ /**
+ * Perform POST HTTP request
+ * @alias ajax
+ */
+ static post(url, data, options) {
+ const params = options || {};
+ params.method = 'POST';
+ return this.ajax(url, data, params);
+ }
+
+ /**
+ * Perform GET HTTP request
+ * @alias ajax
+ */
+ static get(url, data, options) {
+ const params = options || {};
+ params.method = 'GET';
+ return this.ajax(url, data, params);
+ }
+
+ /**
+ * Perform ajax HTTP request
+ * @param {string} url Request URL
+ * @param {Object|HTMLFormElement} [data] Optional request parameters: key/value pairs or form element
+ * @param {Object} [options] Optional options
+ * @param {string} [options.method='GET'] Optional query method, default 'GET'
+ * @return {Promise}
+ */
+ static ajax(url, data, options) {
+ const params = [];
+ data = data || {};
+ options = options || {};
+ const method = options.method || 'GET';
+ const xhr = new XMLHttpRequest();
+ return new Promise((resolve, reject) => {
+ xhr.onreadystatechange = function () {
+ if (xhr.readyState !== XMLHttpRequest.DONE) { return; }
+ let message = '';
+ let error = true;
+ if (xhr.status === 200) {
+ try {
+ const obj = JSON.parse(xhr.responseText);
+ if (obj) {
+ if (!obj.error) {
+ if (resolve && typeof resolve === 'function') {
+ resolve(obj);
+ }
+ error = false;
+ } else if (obj.message) {
+ message = obj.message;
+ }
+ }
+ } catch (err) {
+ message = err.message;
+ }
+ } else {
+ message = `HTTP error ${xhr.status}`;
+ }
+ if (error && reject && typeof reject === 'function') {
+ reject(message);
+ }
+ };
+ let body = null;
+ if (data instanceof HTMLFormElement) {
+ if (method === 'POST') {
+ body = new FormData(data);
+ } else {
+ body = new URLSearchParams(new FormData(data)).toString();
+ }
+ } else {
+ for (const key in data) {
+ if (data.hasOwnProperty(key)) {
+ params.push(key + '=' + encodeURIComponent(data[key]));
+ }
+ }
+ body = params.join('&');
+ body = body.replace(/%20/g, '+');
+ }
+ if (method === 'GET' && body.length) {
+ url += '?' + body;
+ body = null;
+ }
+ xhr.open(method, url, true);
+ if (method === 'POST' && !(data instanceof HTMLFormElement)) {
+ xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
+ }
+ xhr.send(body);
+ });
+ }
+}
diff --git a/js/test/ajax.test.js b/js/test/ajax.test.js
new file mode 100644
index 0000000..17f7450
--- /dev/null
+++ b/js/test/ajax.test.js
@@ -0,0 +1,157 @@
+/*
+ * μlogger
+ *
+ * Copyright(C) 2019 Bartek Fabiszewski (www.fabiszewski.net)
+ *
+ * This is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+import uAjax from '../src/ajax.js';
+
+describe('Ajax tests', () => {
+
+ const url = 'http://ulogger.test/';
+ const validResponse = { id: 1 };
+ const invalidResponse = 'invalid';
+ const errorResponse = { error: true, message: 'response error' };
+ const form = document.createElement('form');
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.name = 'p1';
+ input.value = 'test';
+ form.appendChild(input);
+
+ beforeEach(() => {
+ spyOn(XMLHttpRequest.prototype, 'open').and.callThrough();
+ spyOn(XMLHttpRequest.prototype, 'setRequestHeader').and.callThrough();
+ spyOn(XMLHttpRequest.prototype, 'send');
+ spyOnProperty(XMLHttpRequest.prototype, 'readyState').and.returnValue(XMLHttpRequest.DONE);
+ });
+
+ it('should make POST request', () => {
+ // when
+ uAjax.post(url);
+ // then
+ expect(XMLHttpRequest.prototype.setRequestHeader).toHaveBeenCalledWith('Content-type', 'application/x-www-form-urlencoded');
+ expect(XMLHttpRequest.prototype.open).toHaveBeenCalledWith('POST', url, true);
+ });
+
+ it('should make GET request', () => {
+ // when
+ uAjax.get(url);
+ // then
+ expect(XMLHttpRequest.prototype.setRequestHeader).not.toHaveBeenCalled();
+ expect(XMLHttpRequest.prototype.open).toHaveBeenCalledWith('GET', url, true);
+ });
+
+ it('should make GET request with parameters', () => {
+ // when
+ uAjax.get(url, { p1: 1, p2: 'test' });
+ // then
+ expect(XMLHttpRequest.prototype.open).toHaveBeenCalledWith('GET', `${url}?p1=1&p2=test`, true);
+ expect(XMLHttpRequest.prototype.send).toHaveBeenCalledWith(null);
+ });
+
+ it('should make POST request with parameters', () => {
+ // when
+ uAjax.post(url, { p1: 1, p2: 'test' });
+ // then
+ expect(XMLHttpRequest.prototype.open).toHaveBeenCalledWith('POST', url, true);
+ expect(XMLHttpRequest.prototype.send).toHaveBeenCalledWith('p1=1&p2=test');
+ });
+
+ it('should make POST request with form data', () => {
+ // when
+ uAjax.post(url, form);
+ // then
+ expect(XMLHttpRequest.prototype.setRequestHeader).not.toHaveBeenCalled();
+ expect(XMLHttpRequest.prototype.open).toHaveBeenCalledWith('POST', url, true);
+ expect(XMLHttpRequest.prototype.send).toHaveBeenCalledWith(new FormData(form));
+ });
+
+ it('should make GET request with form data', () => {
+ // when
+ uAjax.get(url, form);
+ // then
+ expect(XMLHttpRequest.prototype.setRequestHeader).not.toHaveBeenCalled();
+ expect(XMLHttpRequest.prototype.open).toHaveBeenCalledWith('GET', `${url}?p1=test`, true);
+ expect(XMLHttpRequest.prototype.send).toHaveBeenCalledWith(null);
+ });
+
+ it('should make successful request and return value', (done) => {
+ // when
+ spyOnProperty(XMLHttpRequest.prototype, 'status').and.returnValue(200);
+ spyOnProperty(XMLHttpRequest.prototype, 'responseText').and.returnValue(JSON.stringify(validResponse));
+ // then
+ uAjax.get(url)
+ .then((result) => {
+ expect(result).toEqual(validResponse);
+ done();
+ })
+ .catch(() => done.fail('reject callback called'));
+ });
+
+ it('should make successful request and return error with message', (done) => {
+ // when
+ spyOnProperty(XMLHttpRequest.prototype, 'status').and.returnValue(200);
+ spyOnProperty(XMLHttpRequest.prototype, 'responseText').and.returnValue(JSON.stringify(errorResponse));
+ // then
+ uAjax.get(url)
+ .then(() => done.fail('resolve callback called'))
+ .catch((message) => {
+ expect(message).toBe(errorResponse.message);
+ done();
+ });
+ });
+
+ it('should make successful request and return error without message', (done) => {
+ // when
+ spyOnProperty(XMLHttpRequest.prototype, 'status').and.returnValue(200);
+ spyOnProperty(XMLHttpRequest.prototype, 'responseText').and.returnValue(JSON.stringify({ error: true }));
+ // then
+ uAjax.get(url)
+ .then(() => done.fail('resolve callback called'))
+ .catch((message) => {
+ expect(message).toBe('');
+ done();
+ });
+ });
+
+ it('should make request and fail with HTTP error code', (done) => {
+ // when
+ const status = 401;
+ spyOnProperty(XMLHttpRequest.prototype, 'status').and.returnValue(status);
+ // then
+ uAjax.get(url)
+ .then(() => done.fail('resolve callback called'))
+ .catch((message) => {
+ expect(message).toBe(`HTTP error ${status}`);
+ done();
+ });
+ });
+
+ it('should make request and fail with JSON parse error', (done) => {
+ // when
+ spyOnProperty(XMLHttpRequest.prototype, 'status').and.returnValue(200);
+ spyOnProperty(XMLHttpRequest.prototype, 'responseText').and.returnValue(invalidResponse);
+ // then
+ uAjax.get(url)
+ .then(() => done.fail('resolve callback called'))
+ .catch((message) => {
+ expect(message).toContain('JSON');
+ done();
+ });
+ });
+
+});