import md5 from 'blueimp-md5';
import superagent from 'superagent';
import xmldom from 'xmldom';
import { isBrowser } from './utils';
const jsonp = require('superagent-jsonp');
/**
* Check if requests will be to the same domain, i.e. no CORS.
* Must be used in a browser environment.
*
* @param url
* @returns {boolean}
*/
const sameOrigin = (url) => {
const a1 = document.createElement('a');
const a2 = document.createElement('a');
a1.href = url;
a2.href = window.location.href;
return a1.hostname === a2.hostname
&& a1.port === a2.port
&& a1.protocol === a2.protocol
&& a2.protocol !== 'file:';
};
/**
* @return {number}
*/
const getPreventCacheNumber = () => parseInt((Math.random() * 10000).toString(), 10);
/**
* This class encapsulates functionality for communicating with the repository via Ajax calls.
* Authentication is done via cookies and accept headers are in general set to
* application/json behind the scenes.
*
* @exports store/Rest
*/
export default class Rest {
constructor() {
this.timeout = 30000; // 30 seconds
this.JSONP = true;
this.headers = {
Accept: 'application/json',
'Content-Type': 'application/json; charset=UTF-8',
};
const rest = this;
if (isBrowser()) {
/**
*
* @param uri
* @param {Object} data
* @param format
* @return {undefined|*}
*/
rest.putFile = (uri, data, format = 'application/json') => {
if (!data.value) {
return undefined;
}
const stubForm = new FormData();
const { files } = data;
Array.from(files).forEach((file, idx) => {
// is the item a File?
if (file instanceof File) {
stubForm.append(idx.toString(), file);
}
});
return superagent.post(uri)
.accept(format)
.withCredentials()
.send(stubForm);
};
}
}
/**
* Disable JSONP for all requests, e.g. when there is a need for performance and there
* is a need for relable caching which does not work with JSONP.
*/
disableJSONP() {
this.JSONP = false;
}
/**
* Enable JSONP for all get requests. JSONP will only be used if EntryStore.js is running in the browser and
* there are cross-site GET requests.
* Note that JSONP is enabled in this scenario by default.
*/
enableJSONP() {
this.JSONP = true;
}
/**
* @param {object} credentials should contain attributes "user", "password", and "maxAge".
* MaxAge is the amount of seconds the authorization should be valid.
* @return {Promise} A thenable object
* @async
*/
async auth(credentials) {
const { user, password, base, maxAge = 604800, logout = false } = credentials;
delete this.headers.cookie;
if (logout) {
const logoutRequestResult = superagent.get(`${base}auth/logout`)
.accept('application/json')
.withCredentials()
.timeout({ response: this.timeout });
Object.entries(this.headers).map(keyVal => logoutRequestResult.set(keyVal[0], keyVal[1]));
return logoutRequestResult;
}
const data = {
auth_username: encodeURIComponent(user),
auth_password: encodeURIComponent(password),
auth_maxage: maxAge,
};
if (isBrowser()) {
return this.post(`${base}auth/cookie`, data, null, 'application/x-www-form-urlencoded');
}
const queryStringData = Object.entries(data).reduce((accum, prop) => `${accum}${prop.join('=')}&`, '');
const authCookieResponse = await this.post(`${base}auth/cookie`, queryStringData, null, 'application/x-www-form-urlencoded');
const cookies = authCookieResponse.headers['set-cookie'];
for (const cookie of cookies) {
if (cookie.startsWith('auth_token=')) {
this.headers.cookie = [cookie];
break;
}
}
return authCookieResponse;
}
/**
* Fetches data from the provided URI.
* If a cross-domain call is made and we are in a browser environment a jsonp call is made.
*
* @param {string} uri - URI to a resource to fetch.
* @param {string|null} format - the format to request as a mimetype.
* @param {boolean} nonJSONP - stop JSONP handling (default false)
* @return {Promise} A thenable object
* @async
* @throws Error
*/
async get(uri, format = null, nonJSONP = false) {
const locHeaders = Object.assign({}, this.headers);
locHeaders['X-Requested-With'] = null;
delete locHeaders['Content-Type'];
let _uri = uri;
let handleAs = 'json';
if (format != null) {
locHeaders.Accept = format;
switch (format) {
case 'application/json': // This is the default in the headers.
break;
case 'application/xml':
case 'text/xml':
handleAs = 'xml';
break;
default: // All other situations, including text/plain.
handleAs = 'text';
}
}
// Use jsonp instead of CORS for GET requests when doing cross-domain calls, it is cheaper
if (isBrowser() && !sameOrigin(_uri) && !nonJSONP && this.JSONP) {
return new Promise((resolve, reject) => {
const queryParameter = new RegExp('[?&]format=');
if (!queryParameter.test(_uri)) {
_uri += `${_uri.includes('?') ? '&' : '?'}format=application/json`;
}
superagent.get(_uri)
.use(
jsonp({
timeout: 1000000,
// @scazan: superagent-jsonp's random number generator is weak, so we create our own
callbackName: `cb${md5(_uri).slice(0, 7)}${getPreventCacheNumber()}`,
}),
) // Need this timeout to prevent a superagentCallback*** not defined issue with superagent-jsonp: https://github.com/lamp/superagent-jsonp/issues/31
.then((data) => {
resolve(data.body);
}, reject);
});
}
const GETRequest = superagent.get(_uri)
.accept(handleAs)
.timeout({
response: this.timeout,
})
.withCredentials();
if (handleAs === 'xml') {
GETRequest.parse['application/xml'] = (res, callback) => {
const DOMParser = isBrowser() ? window.DOMParser : xmldom.DOMParser;
const parser = new DOMParser();
if (isBrowser()) {
return parser.parseFromString(res, 'application/xml');
}
// @todo @valentino check if here it should be an else and callback outside that
// Node handles the return as a callback
res.text = parser.parseFromString(res.text, 'application/xml');
callback(null, res);
return res.text;
};
}
Object.entries(locHeaders).map(keyVal => GETRequest.set(keyVal[0], keyVal[1]));
const response = await GETRequest;
if (response.statusCode === 200) {
if (handleAs === 'text' || format === 'text/xml') {
return response.text;
}
return response.body;
}
throw new Error(`Resource could not be loaded: ${response.text}`);
}
/**
* Posts data to the provided URI.
*
* @param {String} uri - an URI to post to.
* @param {String|Object} data - the data to post. If an object the data is sent as form data.
* @param {Date=} modDate a date to use for the HTTP if-unmodified-since header.
* @param {string=} format - indicates the content-type of the data, default is
* application/json, except if the data is an object in which case the default is
* multipart/form-data.
* @return {Promise} A thenable object
*/
post(uri, data, modDate, format) {
const locHeaders = Object.assign({}, this.headers);
if (modDate) {
locHeaders['If-Unmodified-Since'] = modDate.toUTCString();
}// multipart/form-data
if (format) {
locHeaders['Content-Type'] = format;
}
const POSTRequest = superagent.post(uri);
if (data) {
POSTRequest.send(data)
// serialize the object into a format that the backend is used to (no JSON strings)
.serialize(obj => Object.entries(obj)
.map(keyVal => `${keyVal[0]}=${keyVal[1]}&`)
.join(''));
}
POSTRequest.withCredentials()
.timeout({ response: this.timeout });
Object.entries(locHeaders).map(keyVal => POSTRequest.set(keyVal[0], keyVal[1]));
return POSTRequest;
}
/**
* Posts data to a factory resource with the intent to create a new resource.
* That is, it posts data and expects a Location header back with information on the created
* resource.
*
* @param {string} uri - factory resource, may include parameters.
* @param {string|Object} data - the data that is to be posted as a string,
* if an object is provided it will be serialized as json.
* @returns {Promise.<String>}
*/
async create(uri, data) {
const response = await this.post(uri, data);
// let location = response.getHeader('Location');
let { location } = response.headers;
// In some weird cases, like when making requests from file:///
// we do not have access to headers.
if (!location && response.body) {
const idx = uri.indexOf('?');
if (idx !== -1) {
location = uri.substr(0, uri.indexOf('?'));
} else {
location = uri;
}
location += `/entry/${JSON.parse(response.body).entryId}`;
}
return location;
}
/**
* Replaces a resource with a new representation.
*
* @param {string} uri the address to put to.
* @param {string|Object} data - the data to put. If an object the data is sent as form data.
* @param {Date=} modDate a date to use for the HTTP if-unmodified-since header.
* @param {string=} format - indicates the content-type of the data, default is
* application/json, except if the data is an object in which case the default is
* multipart/form-data.
* @return {Promise} A thenable object
*/
put(uri, data, modDate, format) {
const locHeaders = Object.assign({}, this.headers);
if (modDate) {
locHeaders['If-Unmodified-Since'] = modDate.toUTCString();
}
if (format) {
locHeaders['Content-Type'] = format;
} else if (typeof data === 'object') {
locHeaders['Content-Type'] = 'application/json'; // @todo perhaps not needed, this is default
}
const putRequest = superagent.put(uri)
.send(data)
.withCredentials()
.timeout({ response: this.timeout });
Object.entries(locHeaders).map(keyVal => putRequest.set(keyVal[0], keyVal[1]));
return putRequest;
}
/**
* Deletes a resource.
*
* @param {String} uri of the resource that is to be deleted.
* @param {Date=} modDate a date to use for the HTTP if-unmodified-since header.
* @return {Promise} A thenable object
*/
del(uri, modDate) {
const locHeaders = Object.assign({}, this.headers);
delete locHeaders['Content-Type'];
if (modDate) {
locHeaders['If-Unmodified-Since'] = modDate.toUTCString();
}
const deleteRequest = superagent.del(uri)
.withCredentials()
.timeout({ response: this.timeout });
Object.entries(locHeaders).map(keyVal => deleteRequest.set(keyVal[0], keyVal[1]));
return deleteRequest;
}
/**
* Post a file to a URI.
* In a browser environment a file is represented via an input tag which references
* the file to be uploaded via its value attribute.
* In node environments the file is represented as a stream constructed via
* fs.createReadStream('file.txt').
*
* > _**Under the hood** the tag is moved into a form in an invisible iframe
* which then is submitted. If there is a response it is provided in a textarea which
* can be looked into since we are on the same domain._
*
* @param {string} uri the URI to which we will put the file.
* @param {data} data - input tag or stream that may for instance correspond to a file
* in a nodejs setting.
* @param {string} format the format to handle the response as, either text, xml, html or json
* (json is default).
* @return {Promise} A thenable object
*/
putFile(uri, data, format) {
return this.post(uri, data, null, format);
}
}