apostrophe
Version:
The Apostrophe Content Management System.
417 lines (393 loc) • 15.1 kB
JavaScript
export default () => {
// Adds the apos.http client, which has the same API
// as the server-side apos.http client, although it may
// not have exactly the same features available.
// This is a lean, IE11-friendly implementation.
const busyActive = {};
const apos = window.apos;
apos.http = {};
// Send a POST request. Note that POST body data should be in
// `options.body`. See `apos.http.remote` for details.
// You do NOT have to pass a callback unless you must support IE11
// and do not want to include a promise polyfill in your build.
apos.http.post = function(url, options, callback) {
return apos.http.remote('POST', url, options, callback);
};
// Send a GET request. Note that query string data may be in
// `options.qs`. See `apos.http.remote` for details.
// You do NOT have to pass a callback unless you must support IE11
// and do not want to include a promise polyfill in your build.
apos.http.get = function(url, options, callback) {
return apos.http.remote('GET', url, options, callback);
};
// Send a PATCH request. Note that PATCH body data should be in
// `options.body`. See `apos.http.remote` for details.
// You do NOT have to pass a callback unless you must support IE11
// and do not want to include a promise polyfill in your build.
apos.http.patch = function(url, options, callback) {
return apos.http.remote('PATCH', url, options, callback);
};
// Send a PUT request. Note that PUT body data should be in
// `options.body`. See `apos.http.remote` for details.
// You do NOT have to pass a callback unless you must support IE11
// and do not want to include a promise polyfill in your build.
apos.http.put = function(url, options, callback) {
return apos.http.remote('PUT', url, options, callback);
};
// Send a DELETE request. See `apos.http.remote` for details.
// You do NOT have to pass a callback unless you must support IE11
// and do not want to include a promise polyfill in your build.
apos.http.delete = function(url, options, callback) {
return apos.http.remote('DELETE', url, options, callback);
};
// Send an HTTP request with the given method to the given URL and return the
// response body.
//
// The callback is optional as long as Promise support is present in the
// browser, directly or as a polyfill. If a callback is used it will receive
// `(err, result)` where `result` is the return value described below.
//
// Accepts the following options:
//
// `qs` (pass object; builds a query string, does not support recursion)
// `send`: by default, `options.body` is sent as JSON if it is an object and
// it is not a `FormData` object. If `send` is set to `json`, it is always
// sent as JSON. `body` (request body, not for GET; if an object or array,
// sent as JSON, otherwise sent as-is, unless the `send` option is set)
// `parse` (can be 'json` to always parse the response body as JSON, otherwise
// the response body is parsed as JSON only if the content-type is
// application/json) `headers` (an object containing header names and values)
// `draft` (if true, always add aposMode=draft to the query string, creating
// one if needed) `fullResponse` (if true, return an object with `status`,
// `headers` and `body` properties, rather than returning the body directly;
// the individual `headers` are canonicalized to lowercase names. If there are
// duplicate headers after canonicalization only the last value is returned.
// If a header appears multiple times an array is returned for it)
// `downloadProgress` (may be a function accepting `received` and `total`
// parameters. May never be called. If called, `received` will be the bytes
// sent so far, and `total` will be the total bytes to be received. If the
// total is unknown, it will be `null`) `uploadProgress` (may be a function
// accepting `sent` and `total` parameters. May never be called. If called,
// `sent` will be the bytes sent so far, and `total` will be the total bytes
// to be sent. If the total is unknown, it will be `null`) `prefix`: If
// explicitly set to `false`, do not automatically prefix the URL, even if the
// site has a site-wide prefix or locale prefix. It can become handy when the
// given url is already prefixed, which is the case when using the document's
// computed `_url` field for instance.
//
// If the status code is >= 400 an error is thrown. The error object will be
// similar to a `fullResponse` object, with a `status` property.
//
// If the URL is site-relative (starts with /) it will be requested from
// the apostrophe site itself.
// Just before the XMLHTTPRequest is sent this method emits an
// `apos-before-post` event on `document.body` (where `post` changes
// to match the method, in lower case). The event object
// has `uri`, `data` and `request` properties. `request` is the
// XMLHTTPRequest object. You can use this to set custom headers
// on all lean requests, etc.
apos.http.remote = function(method, url, options, callback) {
if (!callback) {
if (!window.Promise) {
throw new Error('If you wish to receive a promise from apos.http methods in older browsers you must have a Promise polyfill. If you do not want to provide one, pass a callback instead.');
}
return new window.Promise(function(resolve, reject) {
if (!url) {
return reject(new Error('url is not defined'));
}
return apos.http.remote(method, url, options, function(err, result) {
if (err) {
return reject(err);
}
return resolve(result);
});
});
}
if (!url) {
return callback(new Error('url is not defined'));
}
if (apos.sitePrefix && options.prefix !== false) {
// Prepend the prefix if the URL is absolute:
if (url.substring(0, 1) === '/') {
url = apos.sitePrefix + url;
}
}
let query;
let qat;
// Intentional true / falsey check for determining
// what set of docs the request is interested in
if (options.draft != null) {
if (options.qs) {
// Already assumes no query parameters baked into URL, so OK to
// just extend qs
options.qs = options.draft
? apos.util.assign({ aposMode: 'draft' }, options.qs)
: apos.util.assign({ aposMode: 'published' }, options.qs);
} else {
// Careful, there could be existing query parameters baked into url
qat = url.indexOf('?');
if (qat !== -1) {
query = apos.http.parseQuery(url.substring(qat));
} else {
query = {};
}
query.aposMode = options.draft ? 'draft' : 'published';
url = apos.http.addQueryToUrl(url, query);
}
}
const busyName = options.busy === true ? 'busy' : options.busy;
const xmlhttp = new XMLHttpRequest();
let data = options.body;
let keys;
let i;
qat = url.indexOf('?');
const qs = {
...qat !== -1 && apos.http.parseQuery(url.substring(qat)),
...options.qs || {},
...options.qs?.aposLocale
? { aposLocale: options.qs.aposLocale }
: { aposLocale: apos.getActiveLocale() }
};
url = apos.http.addQueryToUrl(url, qs);
if (options.busy) {
if (!busyActive[busyName]) {
busyActive[busyName] = 0;
if (apos.bus) {
apos.bus.$emit('busy', {
active: true,
name: busyName
});
}
}
// keep track of nested calls
busyActive[busyName]++;
}
xmlhttp.open(method, url);
const formData = window.FormData && (data instanceof window.FormData);
const sendJson = (options.send === 'json') || (options.body && ((typeof options.body) === 'object') && !formData);
if (sendJson) {
xmlhttp.setRequestHeader('Content-Type', 'application/json');
}
if (options.headers) {
keys = Object.keys(options.headers);
for (i = 0; (i < keys.length); i++) {
xmlhttp.setRequestHeader(keys[i], options.headers[keys[i]]);
}
}
apos.util.emit(document.body, 'apos-before-' + method.toLowerCase(), {
uri: url,
data: options.body,
request: xmlhttp
});
if (sendJson) {
data = JSON.stringify(options.body);
} else {
data = options.body;
}
xmlhttp.addEventListener('load', function() {
let data = null;
const responseHeader = this.getResponseHeader('Content-Type');
if (responseHeader || (options.parse === 'json')) {
if ((options.parse === 'json') || (responseHeader.match(/^application\/json/))) {
try {
data = JSON.parse(this.responseText);
} catch (e) {
return callback(e);
}
} else {
data = this.responseText;
}
}
if (xmlhttp.status < 400) {
if (options.fullResponse) {
return callback(null, {
body: data,
status: xmlhttp.status,
headers: getHeaders()
});
} else {
return callback(null, data);
}
} else {
const error = new Error((data && data.message) || (data && data.name) || 'Error');
error.status = xmlhttp.status;
error.name = (data && data.name);
error.body = data;
error.headers = getHeaders();
return callback(error);
}
});
xmlhttp.addEventListener('abort', function(evt) {
return callback(evt);
});
xmlhttp.addEventListener('error', function(evt) {
return callback(evt);
});
if (options.downloadProgress) {
xmlhttp.addEventListener('progress', function(evt) {
options.downloadProgress(evt.loaded, evt.lengthComputable ? evt.total : null);
});
}
if (xmlhttp.upload && options.uploadProgress) {
xmlhttp.upload.addEventListener('progress', function(evt) {
options.uploadProgress(evt.loaded, evt.lengthComputable ? evt.total : null);
});
}
xmlhttp.addEventListener('loadend', function (evt) {
if (options.busy) {
busyActive[busyName]--;
if (!busyActive[busyName]) {
// if no nested calls, disable the "busy" state
if (apos.bus) {
apos.bus.$emit('busy', {
active: false,
name: busyName
});
}
}
}
});
xmlhttp.send(data);
function getHeaders() {
const headers = xmlhttp.getAllResponseHeaders();
if (!headers) {
return {};
}
// Per MDN
const arr = headers.trim().split(/[\r\n]+/);
// Create a map of header names to values
const headerMap = {};
arr.forEach(function (line) {
const parts = line.split(': ');
const header = parts.shift();
if (!header) {
return;
}
const value = parts.shift();
// Optional support for fetching arrays of headers with the same name
// could be added at a later time if anyone really cares. Usually
// just a source of bugs
headerMap[header.toLowerCase()] = value;
});
return headerMap;
}
};
// Parse a query string. You can pass with or without the
// leading ?. Don't pass the entire URL. Supports objects,
// arrays and nesting with the classic PHP/Java bracket syntax.
// If a key is set with no = it is considered null, per
// the java convention. Good for use with window.location.search.
apos.http.parseQuery = function(query) {
query = query.replace(/^\?/, '');
const data = {};
const pairs = query.split('&');
pairs.forEach(function(pair) {
let parts;
if (pair.indexOf('=') === -1) {
patch(pair, null);
} else {
parts = pair.split('=');
if (parts) {
patch(parts[0], parts[1]);
}
}
});
return data.root || {};
function patch(key, value) {
let match;
let parentKey = 'root';
let context = data;
key = decodeURIComponent(key);
const path = key.split('[');
path.forEach(function(subKey) {
if (subKey === ']') {
if (!Array.isArray(context[parentKey])) {
context[parentKey] = [];
}
context = context[parentKey];
parentKey = context.length;
} else if (subKey.match(/^\d+]/)) {
match = subKey.match(/^\d+/);
if (!Array.isArray(context[parentKey])) {
context[parentKey] = [];
}
context = context[parentKey];
parentKey = parseInt(match);
} else {
match = subKey.replace(']', '');
if (!context[parentKey]) {
context[parentKey] = {};
}
context = context[parentKey];
parentKey = match;
}
});
value = (value === null) ? value : decodeURIComponent(value);
if (Array.isArray(context[parentKey])) {
context[parentKey].push(value);
} else if (context[parentKey] !== undefined) {
context[parentKey] = [ context[parentKey], value ];
} else {
context[parentKey] = value;
}
}
};
// Adds query string data to url. Supports nested structures with objects
// and arrays, in a way compatible with qs and most other parsers including
// those baked into PHP frameworks etc. If the URL already contains a query
// it is discarded and replaced with the new one. All non-query parts of the
// URL remain unchanged.
apos.http.addQueryToUrl = function(url, data) {
let hash = '';
const hashAt = url.indexOf('#');
if (hashAt !== -1) {
hash = url.substring(hashAt);
url = url.substring(0, hashAt);
}
url = url.replace(/\?.*$/, '');
let i;
let flat;
if ((data != null) && ((typeof data) === 'object')) {
flat = flatten('', data);
for (i = 0; (i < flat.length); i++) {
const key = flat[i][0];
const val = flat[i][1];
if (i > 0) {
url += '&';
} else {
url += '?';
}
if (val == null) {
// Java-style distinction between null and empty string
url += encodeURIComponent(key);
} else {
url += encodeURIComponent(key) + '=' + encodeURIComponent(val);
}
}
}
return url + hash;
function flatten(path, data) {
let flat = [];
let keys;
let i;
if (Array.isArray(data)) {
for (i = 0; (i < data.length); i++) {
insert(i, data[i]);
}
} else {
keys = Object.keys(data);
for (i = 0; (i < keys.length); i++) {
insert(keys[i], data[keys[i]]);
}
}
return flat;
function insert(key, datum) {
if ((datum != null) && ((typeof datum) === 'object')) {
flat = flat.concat(flatten(path.length ? path + '[' + key + ']' : key, datum));
} else {
flat.push([ path.length ? path + '[' + key + ']' : key, datum ]);
}
}
}
};
};