2016-03-29 15:38:48 +13:00
|
|
|
import fetch from 'isomorphic-fetch';
|
|
|
|
import es6promise from 'es6-promise';
|
2016-04-11 18:21:33 +12:00
|
|
|
import qs from 'qs';
|
2016-04-18 08:25:53 +12:00
|
|
|
import merge from 'merge';
|
2016-04-11 18:21:33 +12:00
|
|
|
|
2016-03-29 15:38:48 +13:00
|
|
|
es6promise.polyfill();
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @see https://github.com/github/fetch#handling-http-error-statuses
|
|
|
|
*/
|
|
|
|
function checkStatus(response) {
|
2016-03-31 10:45:54 +13:00
|
|
|
let ret;
|
|
|
|
let error;
|
2016-03-29 15:38:48 +13:00
|
|
|
if (response.status >= 200 && response.status < 300) {
|
2016-03-31 10:45:54 +13:00
|
|
|
ret = response;
|
2016-03-29 15:38:48 +13:00
|
|
|
} else {
|
2016-03-31 10:45:54 +13:00
|
|
|
error = new Error(response.statusText);
|
|
|
|
error.response = response;
|
|
|
|
throw error;
|
2016-03-29 15:38:48 +13:00
|
|
|
}
|
2016-03-31 10:45:54 +13:00
|
|
|
|
|
|
|
return ret;
|
2016-03-29 15:38:48 +13:00
|
|
|
}
|
2016-03-16 13:30:39 +13:00
|
|
|
|
|
|
|
class SilverStripeBackend {
|
|
|
|
|
2016-03-31 10:45:54 +13:00
|
|
|
constructor() {
|
|
|
|
// Allow mocking
|
|
|
|
this.fetch = fetch;
|
|
|
|
}
|
2016-03-29 15:38:48 +13:00
|
|
|
|
2016-04-11 18:21:33 +12:00
|
|
|
/**
|
|
|
|
* Create an endpoint fetcher from an endpoint spec.
|
|
|
|
*
|
|
|
|
* An endpoint fetcher is a anonymous function that returns a Promise.
|
|
|
|
* The function receives an JS object with properties, and will pass another JS object to
|
|
|
|
* the handler callbacks attached Promise. Other consumers don't need to deal with payload
|
|
|
|
* encoding, etc.
|
|
|
|
*
|
2016-04-12 14:31:14 +12:00
|
|
|
* The intent is that your endpoint spec can keep track of the mechanics of interacting with the
|
|
|
|
* backend server, and your application code can just pass a JS object endpoint fetcher. This also
|
|
|
|
* simplifies mocking.
|
|
|
|
*
|
|
|
|
* # Endpoint Specification
|
|
|
|
*
|
2016-04-11 18:21:33 +12:00
|
|
|
* An endpoint spec is a JS object with the following properties:
|
|
|
|
*
|
|
|
|
* - url: A fully-qualified URL
|
|
|
|
* - method: 'get', 'post', 'put', or 'delete'
|
|
|
|
* - payloadFormat: the content-type of the request data.
|
|
|
|
* - responseFormat: the content-type of the response data. Decoding will be handled for you.
|
2016-04-12 14:31:14 +12:00
|
|
|
* - payloadSchema: Definition for how the payload data passed into the created method
|
|
|
|
* will be processed. See "Payload Schema"
|
2016-04-18 08:25:53 +12:00
|
|
|
* - defaultData: Data to merge into the payload
|
|
|
|
* (which is passed into the returned method when invoked)
|
2016-04-11 18:21:33 +12:00
|
|
|
*
|
2016-04-12 14:31:14 +12:00
|
|
|
* # Payload Formats
|
2016-04-11 18:21:33 +12:00
|
|
|
*
|
2016-04-12 14:31:14 +12:00
|
|
|
* Both `payloadFormat` and `responseFormat` can use the following shortcuts for their
|
|
|
|
* corresponding mime types:
|
2016-04-11 18:21:33 +12:00
|
|
|
*
|
2016-04-18 08:45:59 +12:00
|
|
|
* - urlencoded: application/x-www-form-urlencoded
|
2016-04-11 18:21:33 +12:00
|
|
|
* - json: application/json
|
|
|
|
*
|
2016-04-12 14:31:14 +12:00
|
|
|
* Requests with `method: 'get'` will automatically be sent as `urlencoded`,
|
|
|
|
* with any `data` passed to the endpoint fetcher being added to the `url`
|
|
|
|
* as query parameters.
|
2016-04-11 18:21:33 +12:00
|
|
|
*
|
2016-04-12 14:31:14 +12:00
|
|
|
* # Payload Schema
|
|
|
|
*
|
|
|
|
* The `payloadSchema` argument can contain one or more keys found in the data payload,
|
|
|
|
* and defines how to transform the request parameters accordingly.
|
|
|
|
*
|
|
|
|
* ```json
|
|
|
|
* let endpoint = createEndpointFetcher({
|
|
|
|
* url: 'http://example.org/:one/:two',
|
|
|
|
* method: 'post',
|
|
|
|
* payloadSchema: {
|
|
|
|
* one: { urlReplacement: ':one', remove: true },
|
|
|
|
* two: { urlReplacement: ':two' },
|
|
|
|
* three: { querystring: true }
|
|
|
|
* }
|
|
|
|
* });
|
|
|
|
* endpoint({one: 1, two: 2, three: 3});
|
|
|
|
* // Calls http://example.org/1/2?three=3 with a HTTP body of '{"two": 2}'
|
|
|
|
* ```
|
|
|
|
* **urlReplacement**
|
|
|
|
*
|
|
|
|
* Can be used to replace template placeholders in the 'url' endpoint spec.
|
|
|
|
* If using it alongside `remove: true`, the original key will be removed from the data payload.
|
|
|
|
*
|
|
|
|
* **querystring**
|
|
|
|
*
|
|
|
|
* Forces a specific key in the `data` payload to be added to the `url`
|
|
|
|
* as a query parameter. This only makes sense for HTTP POST/PUT/DELETE requests,
|
|
|
|
* since all `data` payload gets added to the URL automatically for GET requests.
|
|
|
|
*
|
|
|
|
* @param {Object} endpointSpec
|
|
|
|
* @return {Function} A function taking one argument (a payload object),
|
|
|
|
* and returns a promise.
|
2016-04-11 18:21:33 +12:00
|
|
|
*/
|
|
|
|
createEndpointFetcher(endpointSpec) {
|
2016-04-12 14:31:14 +12:00
|
|
|
/**
|
|
|
|
* Encode a payload based on the given contentType
|
|
|
|
*
|
|
|
|
* @param {string} contentType
|
|
|
|
* @param {Object} data
|
|
|
|
* @return {string}
|
|
|
|
*/
|
2016-04-11 18:21:33 +12:00
|
|
|
function encode(contentType, data) {
|
|
|
|
switch (contentType) {
|
2016-04-18 08:45:59 +12:00
|
|
|
case 'application/x-www-form-urlencoded':
|
2016-04-11 18:21:33 +12:00
|
|
|
return qs.stringify(data);
|
|
|
|
|
|
|
|
case 'application/json':
|
|
|
|
case 'application/x-json':
|
|
|
|
case 'application/x-javascript':
|
|
|
|
case 'text/javascript':
|
|
|
|
case 'text/x-javascript':
|
|
|
|
case 'text/x-json':
|
|
|
|
return JSON.stringify(data);
|
|
|
|
|
|
|
|
default:
|
|
|
|
throw new Error(`Can\'t encode format: ${contentType}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-04-12 14:31:14 +12:00
|
|
|
/**
|
|
|
|
* Decode a payload based on the given contentType
|
|
|
|
*
|
|
|
|
* @param {string} contentType
|
|
|
|
* @param {string} text
|
|
|
|
* @return {Object}
|
|
|
|
*/
|
2016-04-11 18:21:33 +12:00
|
|
|
function decode(contentType, text) {
|
|
|
|
switch (contentType) {
|
2016-04-18 08:45:59 +12:00
|
|
|
case 'application/x-www-form-urlencoded':
|
2016-04-11 18:21:33 +12:00
|
|
|
return qs.parse(text);
|
|
|
|
|
|
|
|
case 'application/json':
|
|
|
|
case 'application/x-json':
|
|
|
|
case 'application/x-javascript':
|
|
|
|
case 'text/javascript':
|
|
|
|
case 'text/x-javascript':
|
|
|
|
case 'text/x-json':
|
|
|
|
return JSON.parse(text);
|
|
|
|
|
|
|
|
default:
|
|
|
|
throw new Error(`Can\'t decode format: ${contentType}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-04-12 14:31:14 +12:00
|
|
|
/**
|
|
|
|
* Add a querystring to a url
|
|
|
|
*
|
|
|
|
* @param {string} url
|
|
|
|
* @param {string} querystring
|
|
|
|
* @return {string}
|
|
|
|
*/
|
2016-04-11 18:21:33 +12:00
|
|
|
function addQuerystring(url, querystring) {
|
2016-04-12 14:31:14 +12:00
|
|
|
if (querystring === '') {
|
|
|
|
return url;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (url.match(/\?/)) {
|
|
|
|
return `${url}&${querystring}`;
|
|
|
|
}
|
|
|
|
|
2016-04-11 18:21:33 +12:00
|
|
|
return `${url}?${querystring}`;
|
|
|
|
}
|
|
|
|
|
2016-04-12 14:31:14 +12:00
|
|
|
/**
|
|
|
|
* Parse the response based on the content type returned
|
|
|
|
*
|
|
|
|
* @param {Promise} response
|
|
|
|
* @return {Promise}
|
|
|
|
*/
|
2016-04-11 18:21:33 +12:00
|
|
|
function parseResponse(response) {
|
|
|
|
return response.text().then(
|
|
|
|
body => decode(response.headers.get('Content-Type'), body)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2016-04-12 14:31:14 +12:00
|
|
|
/**
|
|
|
|
* Apply the payload schema rules to the passed-in payload,
|
|
|
|
* returning the transformed payload.
|
|
|
|
*
|
|
|
|
* @param {Object} payloadSchema
|
|
|
|
* @param {Object} data
|
|
|
|
* @return {Object}
|
|
|
|
*/
|
|
|
|
function applySchemaToData(payloadSchema, data) {
|
|
|
|
return Object.keys(data).reduce((prev, key) => {
|
|
|
|
const schema = payloadSchema[key];
|
|
|
|
|
|
|
|
// Remove key if schema requires it.
|
|
|
|
// Usually set because the specific payload key
|
|
|
|
// is used to populate a url placeholder instead.
|
|
|
|
if (schema && (schema.remove === true || schema.querystring === true)) {
|
|
|
|
return prev;
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO Support for nested keys
|
|
|
|
return Object.assign(prev, { [key]: data[key] });
|
|
|
|
}, {});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Applies URL templating and query parameter transformation based on the payloadSchema.
|
|
|
|
*
|
|
|
|
* @param {Object} payloadSchema
|
|
|
|
* @param {string} url
|
|
|
|
* @param {Object} data
|
|
|
|
* @param {Object} opts
|
|
|
|
* @return {string} New URL
|
|
|
|
*/
|
|
|
|
function applySchemaToUrl(payloadSchema, url, data, opts = { setFromData: false }) {
|
|
|
|
let newUrl = url;
|
|
|
|
|
|
|
|
// Set query parameters
|
|
|
|
const queryData = Object.keys(data).reduce((prev, key) => {
|
|
|
|
const schema = payloadSchema[key];
|
|
|
|
const includeThroughSetFromData = (
|
|
|
|
opts.setFromData === true
|
|
|
|
&& !(schema && schema.remove === true)
|
|
|
|
);
|
|
|
|
const includeThroughSpec = (
|
|
|
|
schema
|
|
|
|
&& schema.querystring === true
|
|
|
|
&& schema.remove !== true
|
|
|
|
);
|
|
|
|
if (includeThroughSetFromData || includeThroughSpec) {
|
|
|
|
return Object.assign(prev, { [key]: data[key] });
|
|
|
|
}
|
|
|
|
|
|
|
|
return prev;
|
|
|
|
}, {});
|
|
|
|
|
|
|
|
newUrl = addQuerystring(
|
|
|
|
newUrl,
|
2016-04-18 08:45:59 +12:00
|
|
|
encode('application/x-www-form-urlencoded', queryData)
|
2016-04-12 14:31:14 +12:00
|
|
|
);
|
|
|
|
|
|
|
|
// Template placeholders
|
|
|
|
newUrl = Object.keys(payloadSchema).reduce((prev, key) => {
|
|
|
|
const replacement = payloadSchema[key].urlReplacement;
|
|
|
|
if (replacement) {
|
|
|
|
return prev.replace(replacement, data[key]);
|
|
|
|
}
|
|
|
|
|
|
|
|
return prev;
|
|
|
|
}, newUrl);
|
|
|
|
|
|
|
|
return newUrl;
|
|
|
|
}
|
|
|
|
|
2016-04-11 18:21:33 +12:00
|
|
|
// Parameter defaults
|
|
|
|
const refinedSpec = Object.assign({
|
|
|
|
method: 'get',
|
2016-04-18 08:45:59 +12:00
|
|
|
payloadFormat: 'application/x-www-form-urlencoded',
|
2016-04-11 18:21:33 +12:00
|
|
|
responseFormat: 'application/json',
|
2016-04-12 14:31:14 +12:00
|
|
|
payloadSchema: {},
|
2016-04-18 08:25:53 +12:00
|
|
|
defaultData: {},
|
2016-04-11 18:21:33 +12:00
|
|
|
}, endpointSpec);
|
|
|
|
|
|
|
|
// Substitute shorcut format values with their full mime types
|
|
|
|
const formatShortcuts = {
|
|
|
|
json: 'application/json',
|
2016-04-18 08:45:59 +12:00
|
|
|
urlencoded: 'application/x-www-form-urlencoded',
|
2016-04-11 18:21:33 +12:00
|
|
|
};
|
|
|
|
['payloadFormat', 'responseFormat'].forEach(
|
|
|
|
(key) => {
|
|
|
|
if (formatShortcuts[refinedSpec[key]]) refinedSpec[key] = formatShortcuts[refinedSpec[key]];
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2016-04-18 08:25:53 +12:00
|
|
|
return (data = {}) => {
|
2016-04-11 18:21:33 +12:00
|
|
|
const headers = {
|
|
|
|
Accept: refinedSpec.responseFormat,
|
|
|
|
'Content-Type': refinedSpec.payloadFormat,
|
|
|
|
};
|
|
|
|
|
2016-04-18 08:25:53 +12:00
|
|
|
const mergedData = merge.recursive({}, refinedSpec.defaultData, data);
|
|
|
|
|
2016-04-12 14:31:14 +12:00
|
|
|
// Replace url placeholders, and add query parameters
|
|
|
|
// from the payload based on the schema spec.
|
|
|
|
const url = applySchemaToUrl(
|
|
|
|
refinedSpec.payloadSchema,
|
|
|
|
refinedSpec.url,
|
2016-04-18 08:25:53 +12:00
|
|
|
mergedData,
|
2016-04-12 14:31:14 +12:00
|
|
|
// Always add full payload data to GET requests.
|
|
|
|
// GET requests with a HTTP body are technically legal,
|
|
|
|
// but throw an error in the WHATWG fetch() implementation.
|
2016-04-14 14:01:50 +12:00
|
|
|
{ setFromData: (refinedSpec.method.toLowerCase() === 'get') }
|
2016-04-12 14:31:14 +12:00
|
|
|
);
|
|
|
|
|
|
|
|
const encodedData = encode(
|
|
|
|
refinedSpec.payloadFormat,
|
|
|
|
// Filter raw data through the defined schema,
|
|
|
|
// potentially removing keys because they're
|
2016-04-18 08:25:53 +12:00
|
|
|
applySchemaToData(refinedSpec.payloadSchema, mergedData)
|
2016-04-12 14:31:14 +12:00
|
|
|
);
|
|
|
|
|
2016-04-14 14:01:50 +12:00
|
|
|
const args = refinedSpec.method.toLowerCase() === 'get'
|
2016-04-12 14:31:14 +12:00
|
|
|
? [url, headers]
|
|
|
|
: [url, encodedData, headers];
|
2016-04-11 18:21:33 +12:00
|
|
|
|
2016-04-12 14:31:14 +12:00
|
|
|
return this[refinedSpec.method](...args)
|
2016-04-11 18:21:33 +12:00
|
|
|
.then(parseResponse);
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2016-03-31 10:45:54 +13:00
|
|
|
/**
|
|
|
|
* Makes a network request using the GET HTTP verb.
|
|
|
|
*
|
2016-04-12 10:24:16 +12:00
|
|
|
* @experimental
|
|
|
|
*
|
2016-03-31 10:45:54 +13:00
|
|
|
* @param string url - Endpoint URL.
|
2016-04-12 10:24:16 +12:00
|
|
|
* @param object data - Data to send with the request.
|
|
|
|
* @param Array headers
|
2016-03-31 10:45:54 +13:00
|
|
|
* @return object - Promise
|
|
|
|
*/
|
2016-04-12 11:21:49 +12:00
|
|
|
get(url, headers = {}) {
|
2016-04-12 10:24:16 +12:00
|
|
|
return this.fetch(url, {
|
|
|
|
method: 'get',
|
|
|
|
credentials: 'same-origin',
|
|
|
|
headers,
|
|
|
|
})
|
2016-03-31 10:45:54 +13:00
|
|
|
.then(checkStatus);
|
|
|
|
}
|
2016-03-16 13:30:39 +13:00
|
|
|
|
2016-03-31 10:45:54 +13:00
|
|
|
/**
|
|
|
|
* Makes a network request using the POST HTTP verb.
|
|
|
|
*
|
|
|
|
* @param string url - Endpoint URL.
|
|
|
|
* @param object data - Data to send with the request.
|
2016-04-12 10:24:16 +12:00
|
|
|
* @param Array headers
|
2016-03-31 10:45:54 +13:00
|
|
|
* @return object - Promise
|
|
|
|
*/
|
2016-04-12 10:24:16 +12:00
|
|
|
post(url, data = {}, headers = {}) {
|
|
|
|
const defaultHeaders = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
2016-04-03 21:04:59 +12:00
|
|
|
return this.fetch(url, {
|
|
|
|
method: 'post',
|
2016-04-12 10:24:16 +12:00
|
|
|
headers: Object.assign({}, defaultHeaders, headers),
|
2016-04-03 21:04:59 +12:00
|
|
|
credentials: 'same-origin',
|
|
|
|
body: data,
|
|
|
|
})
|
|
|
|
.then(checkStatus);
|
2016-03-31 10:45:54 +13:00
|
|
|
}
|
2016-03-16 13:30:39 +13:00
|
|
|
|
2016-03-31 10:45:54 +13:00
|
|
|
/**
|
|
|
|
* Makes a newtwork request using the PUT HTTP verb.
|
|
|
|
*
|
|
|
|
* @param string url - Endpoint URL.
|
|
|
|
* @param object data - Data to send with the request.
|
2016-04-12 10:24:16 +12:00
|
|
|
* @param Array headers
|
2016-03-31 10:45:54 +13:00
|
|
|
* @return object - Promise
|
|
|
|
*/
|
2016-04-12 10:24:16 +12:00
|
|
|
put(url, data = {}, headers = {}) {
|
|
|
|
return this.fetch(url, { method: 'put', credentials: 'same-origin', body: data, headers })
|
2016-03-31 10:45:54 +13:00
|
|
|
.then(checkStatus);
|
|
|
|
}
|
2016-03-16 13:30:39 +13:00
|
|
|
|
2016-03-31 10:45:54 +13:00
|
|
|
/**
|
|
|
|
* Makes a newtwork request using the DELETE HTTP verb.
|
|
|
|
*
|
|
|
|
* @param string url - Endpoint URL.
|
|
|
|
* @param object data - Data to send with the request.
|
2016-04-12 10:24:16 +12:00
|
|
|
* @param Array headers
|
2016-03-31 10:45:54 +13:00
|
|
|
* @return object - Promise
|
|
|
|
*/
|
2016-04-12 10:24:16 +12:00
|
|
|
delete(url, data = {}, headers = {}) {
|
|
|
|
return this.fetch(url, { method: 'delete', credentials: 'same-origin', body: data, headers })
|
2016-03-31 10:45:54 +13:00
|
|
|
.then(checkStatus);
|
|
|
|
}
|
2016-03-16 13:30:39 +13:00
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// Exported as a singleton so we can implement things like
|
|
|
|
// global caching and request batching at some stage.
|
2016-03-31 10:45:54 +13:00
|
|
|
const backend = new SilverStripeBackend();
|
2016-03-16 13:30:39 +13:00
|
|
|
|
|
|
|
export default backend;
|