"use strict"
/**
* The globally available RestJS library
* @class Rest
*/
var Rest = {}
/**
* The default configuration options
*
* @property {String} baseUrl The base URL for requests. I.e, if the `baseUrl` is set to `http://google.com`, all requests will be prefixed with `http://google.com`
* @property {Object} defaultParams The default parameters for requests. Can be overriden by specific requests
* @property {Object} fields The special fields used to determine url (and later, header) information
* @property {Object} fields.id The property that RestJS should use as the id. This will be used for subsequent requests, such as DELETE, PUT or PATCH requests: `<baseUrl>/<resource>/<id field>`
* @property {Array.<Array.<String>>} headers The default headers for requests, defaults to an empty array, expected elements: `[String, String]`
* @property {String} responseType The response type for the request. See [the docs for `XMLHttpRequest.responseType`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType)
* @property {number} [timeout] The XHR timeout. See [the docs for `XMLHttpRequest.timeout`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/timeout)
* @property {boolean} [withCredentials] Whether to send CORS credentials with the request or not. See [the docs for `XMLHttpRequest.withCredentials`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials)
*
* @name Rest#Config
* @class
*/
Rest.Config = {
// timeout: undefined
// withCredentials: undefined
baseUrl: "",
defaultParams: {},
fields: {
id: "id"
},
headers: [],
responseType: "json",
}
/**
* Sets a given config to the configuration object
* @param {Object} config The specified config
* @memberOf Rest#Config
* @example
* Rest.Config.set({baseUrl: 'https://restjs.js.org'})
*/
Rest.Config.set = function(config) {
Object.assign(Rest.Config, config)
}
/**
* Holds the response interceptors
* @see Rest.addResponseInterceptor
*
* @type {Array}
* @private
*/
Rest._responseInterceptors = []
/**
* Adds a response interceptor, which is run when a response is received
*
* @example
*
* Rest.addResponseInterceptor(function(data, responseType, route, responseURL, reject, xhr)) {
* data: The response data
* responseType: The response type. See {@link https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType |the docs}
* route: The route used for {@link _makeRequest}
* responseURL: See {@link https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseURL |the docs for `XMLHttpRequest.responseURL`}
* reject: The `reject` method from the Promise for the request
* xhr: The XHR object used to make the request
* })
*
* Expected format:
*
* function(data, responseType, route, responseURL, reject)
*
* @param {Function} func The interceptor (see the example)
*/
Rest.addResponseInterceptor = function(func) {
Rest._responseInterceptors.push(func)
}
/**
* Holds response extractors
* @see Rest.addResponseExtractor
*
* @type {Array}
* @private
*/
Rest._responseExtractors = []
/**
* Adds a response extractor, which manipulates the data after the response is received, but _after_ interceptors are run
*
* @example <caption>MAKE SURE YOU RETURN THE DATA. If you don't, the request will not resolve with data</caption>
*
* Rest.addResponseExtractor(function(data) {
* return data
* })
*
*
* @param {Function} func The extractor
*/
Rest.addResponseExtractor = function(func) {
Rest._responseExtractors.push(func)
}
/**
* Holds response extractors
* @see Rest.addRequestInterceptor
*
* @type {Array}
* @private
*/
Rest._requestInterceptors = []
/**
* Adds a response interceptor, which is run before a request is sent
*
* @example
*
* Rest.addRequestInterceptor(function(requestConfig, xhr, reject)) {
* requestConfig.route // the URL for the request, minus the
* requestConfig.params // the parameters for the request
* requestConfig.body // the request body
* })
*
* Expected format:
*
* function(requestConfig, xhr, reject)
*
* @param {Function} func The interceptor (see the example)
*/
Rest.addRequestInterceptor = function (func) {
Rest._requestInterceptors.push(func)
}
/**
* The factory creator method for RestJS
* @param {String} route The route
* @param {(Object|Function)} factoryTransformer A transformer that is either added to the factory (if it's an object) or run on the factory (if it's a function)
* @param {(Object|Function)} elementTransformer A transformer that is either added to the element (if it's an object) or run on the element (if it's a function)
* @param {Object} customConfig A custom configuration object @see Rest.Config
* @return {Factory} A newly created Factory
*/
Rest.factory = function(route, factoryTransformer, elementTransformer, collectionTransformer, customConfig=null) {
// Create the Factory, passing the necessary property descriptors
let factory = Object.create(Factory, {
/**
* The route
* @type {String}
* @memberOf Factory
* @instance
*/
route: {
configurable: false,
enumerable: false,
value: route.replace(/^\/|\/$/g, "")
},
/**
* The configuration
* @type {Object}
* @memberOf Factory
* @instance
*/
config: {
configurable: false,
enumerable: false,
value: customConfig || Rest.Config
},
/**
* The transformer to be run on each element
* @type {Function}
* @memberOf Factory
* @instance
*/
elementTransformer: {
configurable: false,
enumerable: false,
value: elementTransformer
},
/**
* The transformer to be run on a collection (array)
* @type {Function}
* @memberOf Factory
* @instance
*/
collectionTransformer: {
configurable: false,
enumerable: false,
value: collectionTransformer
}
})
// Make sure the prototype is set either to the transformer (if it's an object), or else, an empty object
let transformer = typeof factoryTransformer == "object" ? factoryTransformer : {}
// Add the transformer methods, or an empty object (see above line)
Object.assign(factory, transformer)
// Only run the transformer if it's a function
if(typeof factoryTransformer == "function")
factory = factoryTransformer(factory)
// All done creating the factory, return it!
return factory
}
/**
* Makes a request with the necessary config
*
* @private
* @param {Object} config
* @param {String} verb The HTTP verb: GET, POST, PATCH, PUT
* @param {String} route
* @param {Object} params={} The URL parameters for the request
* @param {Factory} factory
* @param {Object} [body] The body of the request
* @return {Promise<xhr.response>} A promise that is resolved or rejected based on the request
*/
Rest._makeRequest = function(config, verb, route, params={}, factory, body) {
// Create a new promise and a new XHR request
let promise = new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest()
// Set the xhr config
if("timeout" in config)
xhr.timeout = config.timeout
xhr.withCredentials = config.withCredentials
xhr.responseType = config.responseType
// This callback is called when the request comes back
xhr.onreadystatechange = function() {
let data = xhr.response
// Check to make sure the request is done
if (xhr.readyState == XMLHttpRequest.DONE) {
// Loop over the interceptors and extractors, running each of them on the response
Rest._responseInterceptors.forEach(function (interceptor) {
interceptor(data, xhr.responseType, route, xhr.responseURL, reject, xhr)
})
Rest._responseExtractors.forEach(function(extractor) {
data = extractor(data)
})
// If the status isn't in between 200 or 299
if (xhr.status < 200 || xhr.status > 299)
return reject({data, xhr})
// Make sure there's data before restifying it, otherwise just resolve with null
if(data && Object.keys(data).length)
resolve(Rest._restify(data, factory, config))
else
resolve(null)
}
}
params = Object.assign({}, Rest.Config.defaultParams, params)
let requestConfig = {route, params, body}
Rest._requestInterceptors.forEach(function (interceptor) {
requestConfig = interceptor(requestConfig, xhr)
})
// Open the XHR request. See {@link https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/open |the docs}
xhr.open(verb, Rest._createUrl(requestConfig.route, requestConfig.params), true)
// @todo: switch based on type
xhr.setRequestHeader("Content-type", "application/json")
// Loop through all the headers and set the header
config.headers.forEach((header) => {
xhr.setRequestHeader(header[0], header[1])
})
// If the body exists and the response type is JSON, stringify it first
// Otherwise, just send it as is,
// Else, just send it without a body
if (requestConfig.body && xhr.responseType == "json")
return xhr.send(JSON.stringify(requestConfig.body))
else if(requestConfig.body)
return xhr.send(requestConfig.body)
else
return xhr.send()
})
return promise
}
/**
* Create a url based off a route and a parameter object
*
* @private
* @todo Fix the config object; pass it in instead of referring to it directly
* @param {String} route The route for the request, from when the factory was created
* @param {Object=} params={} The URL parameters for the request
* @return {String} The created url, complete with the baseURL prepended and the parameters URL-encoded and appended
*/
Rest._createUrl = function(route, params={}) {
// Fetch the baseUrl from configuration, stripping all trailing slashes
let baseUrl = Rest.Config.baseUrl.replace(/\/+$/, "")
// Url-encode the params
// @todo: Add support for arrays and objects
let encodedParams = Object.keys(params).reduce(function (array, key) {
// Push each element onto the array and
array.push(`${key}=${encodeURIComponent(params[key])}`)
return array
// Join the array of (now encoded) params with &'s
}, []).join("&")
// Return the entire URL, optionally adding the parameter string if it exists
return `${baseUrl}/${route}` + (encodedParams ? `?${encodedParams}` : "")
}
/**
* "Restifies" an element: add the default Rest methods to the prototype, and run it through the transformer
*
* @private
* @param {Object} response The response object from [`xhr.response`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/response)
* @param {Factory} factory The factory object that created the request
* @param {Object} config The configuration for the element
* @return {Element} The newly created Element, as returned by {@link Factory.create}
*/
Rest._restify = function(response, factory, config) {
// If the passed-in response an array
if (Array.isArray(response)) {
if(typeof factory.collectionTransformer == "function")
factory.collectionTransformer(response)
// Loop over the array, restify each of the elements and return the new array
let restifiedResponse = response.reduce(function(array, element) {
array.push(Rest._restify(element, factory, config))
return array
}, [])
if (typeof factory.collectionTransformer == "object")
Object.assign(restifiedResponse, factory.collectionTransformer)
return restifiedResponse
}
// If it's not an array, call the create method, passing in the response, and set `fromServer` to true (the last parameter)
return factory.create.call(factory, response, true)
}
/**
* Takes the arguments from a function call, figuring out what's what
*
* The arguments is an array of parameters in three different formats. The last parameter is always an optional parameters object.<br />
*
*
* The first arguments are strings, denoting the properties to send in the request. The last argument is the parameters object<br />
* `(String..., [Object])`
*
* The first argument is a list of the properties that should be sent in the request. The second (and last) is the parameters object<br />
* `(Array, [Object])`
*
* The first argument is the body that should be sent in the request. The second…well…you can guess: it's our old friend, the parameters object! :)<br />
* `(Object, [Object])`
*
* @private
* @param {Array} args The arguments array from the function call
* @param {Element} element The Element in question
* @return {Object} `{body: Object, params: Object}`
* @example let {body, params} = Rest._findBodyAndParams(args, element)
*
* @memberOf Rest
*/
Rest._findBodyAndParams = function(args, element) {
// Set the defaults for the body & params
let body = {}, params
// If the first argument is an array
if(args[0] instanceof Array) {
// only patch the passed-in properties
args[0].forEach(function (property) {
body[property] = element[property]
})
// Then the params are the second argument
params = args[1] || {}
// If the first param is an object, the body has been passed in directly
} else if(typeof args[0] == "object") {
body = args[0]
params = args[1] || {}
// Else if the first argument is a string, the properties have been passed in as args
} else if(typeof args[0] == "string") {
// Loop through the args
for (var i = 0; i < args.length; i++) {
var arg = args[i]
// If the arg is a string, it's a property of the element
if(typeof arg == "string") {
body[arg] = element[arg]
// Else if it's an object and the last argument, it's the parameters object…so set it
} else if(typeof arg == "object" && i == args.length - 1) {
params = arg
}
}
}
// Return it as a "tuple" of sorts
return {body, params}
}