traverson
Version:
Hypermedia API/HATEOAS client for Node.js and the browser
762 lines (690 loc) • 26.2 kB
JavaScript
'use strict';
var minilog = require('minilog')
, standardRequest = require('request')
, util = require('util');
var actions = require('./actions')
, abortTraversal = require('./abort_traversal').abortTraversal
, errorModule = require('./errors')
, errors = errorModule.errors
, createError = errorModule.createError
, mediaTypeRegistry = require('./media_type_registry')
, mediaTypes = require('./media_types')
, mergeRecursive = require('./merge_recursive');
var log = minilog('traverson');
// Maintenance notice: The constructor is usually called without arguments, the
// mediaType parameter is only used when cloning the request builder in
// newRequest().
function Builder(mediaType, linkType) {
this.mediaType = mediaType || mediaTypes.CONTENT_NEGOTIATION;
this.linkType = linkType || 'link-rel';
this.adapter = this._createAdapter(this.mediaType);
this.autoHeaders = true;
this.contentNegotiation = true;
this.rawPayloadFlag = false;
this.convertResponseToObjectFlag = false;
this.links = [];
this.jsonParser = JSON.parse;
this.requestModuleInstance = standardRequest;
this.requestOptions = {};
this.resolveRelativeFlag = false;
this.preferEmbedded = false;
this.lastTraversalState = null;
this.continuation = null;
// Maintenance notice: when extending the list of configuration parameters,
// also extend this.newRequest and initFromTraversalState
}
Builder.prototype._createAdapter = function(mediaType) {
var AdapterType = mediaTypeRegistry.get(mediaType);
if (!AdapterType) {
throw createError('Unknown or unsupported media type: ' + mediaType,
errors.UnsupportedMediaType);
}
log.debug('creating new ' + AdapterType.name);
return new AdapterType(log);
};
/**
* Returns a new builder instance which is basically a clone of this builder
* instance. This allows you to initiate a new request but keeping all the setup
* (start URL, template parameters, request options, body parser, ...).
*/
Builder.prototype.newRequest = function() {
var clonedRequestBuilder = new Builder(this.getMediaType(),
this.getLinkType());
clonedRequestBuilder.useAutoHeaders(this.setsAutoHeaders());
clonedRequestBuilder.contentNegotiation =
this.doesContentNegotiation();
clonedRequestBuilder.convertResponseToObject(this.convertsResponseToObject());
clonedRequestBuilder.from(shallowCloneArray(this.getFrom()));
clonedRequestBuilder.withTemplateParameters(
cloneArrayOrObject(this.getTemplateParameters()));
clonedRequestBuilder.withRequestOptions(
cloneArrayOrObject(this.getRequestOptions()));
clonedRequestBuilder.withRequestLibrary(this.getRequestLibrary());
clonedRequestBuilder.parseResponseBodiesWith(this.getJsonParser());
clonedRequestBuilder.resolveRelative(this.doesResolveRelative());
clonedRequestBuilder.preferEmbeddedResources(
this.doesPreferEmbeddedResources());
clonedRequestBuilder.continuation = this.continuation;
// Maintenance notice: when extending the list of configuration parameters,
// also extend initFromTraversalState
return clonedRequestBuilder;
};
/**
* Disables content negotiation and forces the use of a given media type.
* The media type has to be registered at Traverson's media type registry
* before via traverson.registerMediaType (except for media type
* application/json, which is traverson.mediaTypes.JSON).
*/
Builder.prototype.setMediaType = function(mediaType) {
this.mediaType = mediaType || mediaTypes.CONTENT_NEGOTIATION;
this.adapter = this._createAdapter(mediaType);
this.contentNegotiation =
(mediaType === mediaTypes.CONTENT_NEGOTIATION);
return this;
};
Builder.prototype.getLinkType = function() {
return this.linkType;
};
/**
* Shortcut for
* setMediaType(traverson.mediaTypes.JSON);
*/
Builder.prototype.json = function() {
this.setMediaType(mediaTypes.JSON);
return this;
};
/**
* Shortcut for
* setMediaType(traverson.mediaTypes.JSON_HAL);
*/
Builder.prototype.jsonHal = function() {
this.setMediaType(mediaTypes.JSON_HAL);
return this;
};
/**
* Enables content negotiation (content negotiation is enabled by default, this
* method can be used to enable it after a call to setMediaType disabled it).
*/
Builder.prototype.useContentNegotiation = function() {
this.setMediaType(mediaTypes.CONTENT_NEGOTIATION);
this.contentNegotiation = true;
return this;
};
/**
* Set the root URL of the API, that is, where the link traversal begins.
*/
Builder.prototype.from = function(url) {
this.startUrl = url;
return this;
};
/**
* Adds link relations to the list of link relations to follow. The initial list
* of link relations is the empty list. Each link relation in this list
* corresponds to one step in the traversal.
*/
Builder.prototype.follow = function() {
var newLinks = Array.prototype.slice.apply(
arguments.length === 1 && util.isArray(arguments[0]) ?
arguments[0] : arguments
);
for (var i = 0; i < newLinks.length; i++) {
if (typeof newLinks[i] === 'string') {
newLinks[i] = {
type: this.linkType,
value: newLinks[i],
};
}
}
this.links = this.links.concat(newLinks);
return this;
};
/**
* Adds a special step to the list of link relations that will follow the
* location header, that is, instead of reading the next URL from a link in the
* document body, it uses the location header and follows the URL from this
* header.
*/
Builder.prototype.followLocationHeader = function() {
this.links.push({
type: 'header',
value: 'location',
});
return this;
};
/**
* Alias for follow.
*/
Builder.prototype.walk = Builder.prototype.follow;
/**
* Provide template parameters for URI template substitution.
*/
Builder.prototype.withTemplateParameters = function(parameters) {
this.templateParameters = parameters;
return this;
};
/**
* Provide options for HTTP requests (additional HTTP headers, for example).
* This function resets any request options, that had been set previously, that
* is, multiple calls to withRequestOptions are not cumulative. Use
* addRequestOptions to add request options in a cumulative way.
*
* Options can either be passed as an object or an array. If an object is
* passed, the options will be used for each HTTP request. If an array is
* passed, each element should be an options object and the first array element
* will be used for the first request, the second element for the second request
* and so on. null elements are allowed.
*/
Builder.prototype.withRequestOptions = function(options) {
this.requestOptions = options;
return this;
};
/**
* Adds options for HTTP requests (additional HTTP headers, for example) on top
* of existing options, if any. To reset all request options and set new ones
* without keeping the old ones, you can use withRequestOptions.
*
* Options can either be passed as an object or an array. If an object is
* passed, the options will be used for each HTTP request. If an array is
* passed, each element should be an options object and the first array element
* will be used for the first request, the second element for the second request
* and so on. null elements are allowed.
*
* When called after a call to withRequestOptions or when combining multiple
* addRequestOptions calls, some with objects and some with arrays, a multitude
* of interesting situations can occur:
*
* 1) The existing request options are an object and the new options passed into
* this method are also an object. Outcome: Both objects are merged and all
* options are applied to all requests.
*
* 2) The existing options are an array and the new options passed into this
* method are also an array. Outcome: Each array element is merged individually.
* The combined options from the n-th array element in the existing options
* array and the n-th array element in the given array are applied to the n-th
* request.
*
* 3) The existing options are an object and the new options passed into this
* method are an array. Outcome: A new options array will be created. For each
* element, a clone of the existing options object will be merged with an
* element from the given options array.
*
* Note that if the given array has less elements than the number of steps in
* the link traversal (usually the number of steps is derived from the number
* of link relations given to the follow method), only the first n http
* requests will use options at all, where n is the number of elements in the
* given array. HTTP request n + 1 and all following HTTP requests will use an
* empty options object. This is due to the fact, that at the time of creating
* the new options array, we can not know with certainty how many steps the
* link traversal will have.
*
* 4) The existing options are an array and the new options passed into this
* method are an object. Outcome: A clone of the given options object will be
* merged into into each array element of the existing options.
*/
Builder.prototype.addRequestOptions = function(options) {
// case 2: both the present options and the new options are arrays.
// => merge each array element individually
if (util.isArray(this.requestOptions) && util.isArray(options)) {
mergeArrayElements(this.requestOptions, options);
// case 3: there is an options object the new options are an array.
// => create a new array, each element is a merge of the existing base object
// and the array element from the new options array.
} else if (typeof this.requestOptions === 'object' &&
util.isArray(options)) {
this.requestOptions =
mergeBaseObjectWithArrayElements(this.requestOptions, options);
// case 4: there is an options array and the new options are an object.
// => merge the new object into each array element.
} else if (util.isArray(this.requestOptions) &&
typeof options === 'object') {
mergeOptionObjectIntoEachArrayElement(this.requestOptions, options);
// case 1: both are objects
// => merge both objects
} else {
mergeRecursive(this.requestOptions, options);
}
return this;
};
function mergeArrayElements(existingOptions, newOptions) {
for (var i = 0;
i < Math.max(existingOptions.length, newOptions.length);
i++) {
existingOptions[i] =
mergeRecursive(existingOptions[i], newOptions[i]);
}
}
function mergeBaseObjectWithArrayElements(existingOptions, newOptions) {
var newOptArray = [];
for (var i = 0;
i < newOptions.length;
i++) {
newOptArray[i] =
mergeRecursive(newOptions[i], existingOptions);
}
return newOptArray;
}
function mergeOptionObjectIntoEachArrayElement(existingOptions, newOptions) {
for (var i = 0;
i < existingOptions.length;
i++) {
mergeRecursive(existingOptions[i], newOptions);
}
}
/**
* Injects a custom request library. When using this method, you should not
* call withRequestOptions or addRequestOptions but instead pre-configure the
* injected request library instance before passing it to withRequestLibrary.
*/
Builder.prototype.withRequestLibrary = function(request) {
this.requestModuleInstance = request;
return this;
};
/**
* Injects a custom JSON parser.
*/
Builder.prototype.parseResponseBodiesWith = function(parser) {
this.jsonParser = parser;
return this;
};
/**
* Disables automatic Accept and Content-Type headers. See useAutoHeaders().
*/
Builder.prototype.disableAutoHeaders = function() {
return this.useAutoHeaders(false);
};
/**
* Enables automatic Accept and Content-Type headers. See useAutoHeaders().
*/
Builder.prototype.enableAutoHeaders = function() {
return this.useAutoHeaders(true);
};
/**
* Enables or disables automatic headers. With automatic headers enabled,
* traverson will set default Accept and the Content-Type headers for HTTP
* requests, unless you provide these headers explicitly with withRequestOptions
* or addRequestOptions.
*
* The header values depend on the media type (see setMediaType()). For example,
* for plain vanilla JSON (that is, when using setMediaType('application/json')
* or the corresponding shortcut .json()), both headers will be sent with the
* value 'application/json'. For HAL (that is, when using
* setMediaType('application/hal+json') or the corresponding shortcut
* jsonHal()), both headers will be sent with the value 'application/hal+json'.
*
* If the method is called without arguments (or the first argument is undefined
* or null), automatic headers are turned on, otherwise the argument is
* interpreted as a boolean flag. If it is a truthy value, auto headers
* are enabled, if it is a falsy value (but not null or undefined), auto headers
* are disabled.
*
* A note about the condition "unless you provide these headers explicitly with
* withRequestOptions or addRequestOptions" in the first paragraph: Traverson
* with automatic headers enabled will only check for the header option
* "Accept" and "Content-Type", not for "accept" or "Content-type" or any other
* variation regarding upper case/lower case letters. So to be on the safe
* side, if you mix auto headers with explicitly specified headers, make sure
* to specify your explicit headers with this exact same combination of upper
* case and lower case letters.
*/
Builder.prototype.useAutoHeaders = function(flag) {
if (typeof flag === 'undefined' || flag === null) {
flag = true;
}
this.autoHeaders = !!flag;
return this;
};
/**
* With this option enabled, the payload of the last request at the end of the
* traversal will be sent as is, without stringifying it. The default is false,
* which means that usually Traverson assumes the payload is passed as a
* JavScript object which will then be stringified (which is the right thing to
* do for JSON based MIME types like application/json. If you want to handle the
* serialization yourself and don't want Traverson to interfere, this option
* should be set to true.
*
* If the method is called without arguments (or the first argument is undefined
* or null), this option is switched on, otherwise the argument is
* interpreted as a boolean flag. If it is a truthy value, the option is
* switched to on, if it is a falsy value (but not null or
* undefined), the option is switched off.
*/
Builder.prototype.sendRawPayload = function(flag) {
if (typeof flag === 'undefined' || flag === null) {
flag = true;
}
this.rawPayloadFlag = !!flag;
return this;
};
/**
* With this option enabled, the body of the response at the end of the
* traversal will be converted into a JavaScript object (for example by passing
* it into JSON.parse) and passing the resulting object into the callback.
* The default is false, which means the full response is handed to the
* callback.
*
* When response body conversion is enabled, you will not get the full
* response, so you won't have access to the HTTP status code or headers.
* Instead only the converted object will be passed into the callback.
*
* Note that the body of any intermediary responses during the traversal is
* always converted by Traverson (to find the next link).
*
* If the method is called without arguments (or the first argument is undefined
* or null), response body conversion is switched on, otherwise the argument is
* interpreted as a boolean flag. If it is a truthy value, response body
* conversion is switched to on, if it is a falsy value (but not null or
* undefined), response body conversion is switched off.
*/
Builder.prototype.convertResponseToObject = function(flag) {
if (typeof flag === 'undefined' || flag === null) {
flag = true;
}
this.convertResponseToObjectFlag = !!flag;
return this;
};
/**
* Switches URL resolution to relative (default is absolute) or back to
* absolute.
*
* If the method is called without arguments (or the first argument is undefined
* or null), URL resolution is switched to relative, otherwise the argument is
* interpreted as a boolean flag. If it is a truthy value, URL resolution is
* switched to relative, if it is a falsy value, URL resolution is switched to
* absolute.
*/
Builder.prototype.resolveRelative = function(flag) {
if (typeof flag === 'undefined' || flag === null) {
flag = true;
}
this.resolveRelativeFlag = !!flag;
return this;
};
/**
* Makes Traverson prefer embedded resources over traversing a link or vice
* versa. This only applies to media types which support embedded resources
* (like HAL). It has no effect when using a media type that does not support
* embedded resources.
*
* It also only takes effect when a resource contains both a link _and_ an
* embedded resource with the name that is to be followed at this step in the
* link traversal process.
*
* If the method is called without arguments (or the first argument is undefined
* or null), embedded resources will be preferred over fetching linked resources
* with an additional HTTP request. Otherwise the argument is interpreted as a
* boolean flag. If it is a truthy value, embedded resources will be preferred,
* if it is a falsy value, traversing the link relation will be preferred.
*/
Builder.prototype.preferEmbeddedResources = function(flag) {
if (typeof flag === 'undefined' || flag === null) {
flag = true;
}
this.preferEmbedded = !!flag;
return this;
};
/**
* Returns the current media type. If no media type is enforced but content type
* detection is used, the string `content-negotiation` is returned.
*/
Builder.prototype.getMediaType = function() {
return this.mediaType;
};
/**
* Returns the URL set by the from(url) method, that is, the root URL of the
* API.
*/
Builder.prototype.getFrom = function() {
return this.startUrl;
};
/**
* Returns the template parameters set by the withTemplateParameters.
*/
Builder.prototype.getTemplateParameters = function() {
return this.templateParameters;
};
/**
* Returns the request options set by the withRequestOptions or
* addRequestOptions.
*/
Builder.prototype.getRequestOptions = function() {
return this.requestOptions;
};
/**
* Returns the custom request library instance set by withRequestLibrary or the
* standard request library instance, if a custom one has not been set.
*/
Builder.prototype.getRequestLibrary = function() {
return this.requestModuleInstance;
};
/**
* Returns the custom JSON parser function set by parseResponseBodiesWith or the
* standard parser function, if a custom one has not been set.
*/
Builder.prototype.getJsonParser = function() {
return this.jsonParser;
};
/**
* Returns true if default Accept and the Content-Type headers will be set
* automatically for HTTP requests.
*/
Builder.prototype.setsAutoHeaders = function() {
return this.autoHeaders;
};
/**
* Returns true if the given payload will be sent without stringifying it first.
*/
Builder.prototype.sendsRawPayload = function() {
return this.rawPayloadFlag;
};
/**
* Returns true if the body of the last response will be converted to a
* JavaScript object before passing the result back to the callback.
*/
Builder.prototype.convertsResponseToObject = function() {
return this.convertResponseToObjectFlag;
};
/**
* Returns the flag controlling if URLs are resolved relative or absolute.
* A return value of true means that URLs are resolved relative, false means
* absolute.
*/
Builder.prototype.doesResolveRelative = function() {
return this.resolveRelativeFlag;
};
/**
* Returns the flag controlling if embedded resources are preferred over links.
* A return value of true means that embedded resources are preferred, false
* means that following links is preferred.
*/
Builder.prototype.doesPreferEmbeddedResources = function() {
return this.preferEmbedded;
};
/**
* Returns true if content negotiation is enabled and false if a particular
* media type is forced.
*/
Builder.prototype.doesContentNegotiation = function() {
return this.contentNegotiation;
};
/**
* Starts the link traversal process and passes the last HTTP response to the
* callback.
*/
Builder.prototype.get = function get(callback) {
log.debug('initiating traversal (get)');
var t = createInitialTraversalState(this);
return actions.get(t, wrapForContinue(this, t, callback, 'get'));
};
/**
* Special variant of get() that does not yield the full http response to the
* callback but instead the already parsed JSON as an object.
*
* This is a shortcut for builder.convertResponseToObject().get(callback).
*/
Builder.prototype.getResource = function getResource(callback) {
log.debug('initiating traversal (getResource)');
this.convertResponseToObjectFlag = true;
var t = createInitialTraversalState(this);
return actions.get(t, wrapForContinue(this, t, callback,
'getResource'));
};
/**
* Special variant of get() that does not execute the last request but instead
* yields the last URL to the callback.
*/
Builder.prototype.getUrl = function getUrl(callback) {
log.debug('initiating traversal (getUrl)');
var t = createInitialTraversalState(this);
return actions.getUrl(t, wrapForContinue(this, t, callback, 'getUrl'));
};
/**
* Alias for getUrl.
*/
Builder.prototype.getUri = Builder.prototype.getUrl;
/**
* Starts the link traversal process and sends an HTTP POST request with the
* given body to the last URL. Passes the HTTP response of the POST request to
* the callback.
*/
Builder.prototype.post = function post(body, callback) {
log.debug('initiating traversal (post)');
var t = createInitialTraversalState(this, body);
return actions.post(t, wrapForContinue(this, t, callback, 'post'));
};
/**
* Starts the link traversal process and sends an HTTP PUT request with the
* given body to the last URL. Passes the HTTP response of the PUT request to
* the callback.
*/
Builder.prototype.put = function put(body, callback) {
log.debug('initiating traversal (put)');
var t = createInitialTraversalState(this, body);
return actions.put(t, wrapForContinue(this, t, callback, 'put'));
};
/**
* Starts the link traversal process and sends an HTTP PATCH request with the
* given body to the last URL. Passes the HTTP response of the PATCH request to
* the callback.
*/
Builder.prototype.patch = function patch(body, callback) {
log.debug('initiating traversal (patch)');
var t = createInitialTraversalState(this, body);
return actions.patch(t, wrapForContinue(this, t, callback, 'patch'));
};
/**
* Starts the link traversal process and sends an HTTP DELETE request to the
* last URL. Passes the HTTP response of the DELETE request to the callback.
*/
Builder.prototype.delete = function del(callback) {
log.debug('initiating traversal (delete)');
var t = createInitialTraversalState(this);
return actions.delete(t, wrapForContinue(this, t, callback, 'delete'));
};
/**
* Alias for delete.
*/
Builder.prototype.del = Builder.prototype.delete;
/**
* Set linkType property indicating that traverson must follow relations from
* header 'link'
*/
Builder.prototype.linkHeader = function() {
this.linkType = 'link-header';
return this;
};
function createInitialTraversalState(self, body) {
var normalizedBody =
(body !== null && typeof body !== undefined) ? body : null;
var traversalState = {
aborted: false,
adapter: self.adapter || null,
body: normalizedBody,
callbackHasBeenCalledAfterAbort: false,
autoHeaders: self.setsAutoHeaders(),
contentNegotiation: self.doesContentNegotiation(),
continuation: null,
rawPayload: self.sendsRawPayload(),
convertResponseToObject: self.convertsResponseToObject(),
links: self.links,
jsonParser: self.getJsonParser(),
requestModuleInstance: self.getRequestLibrary(),
requestOptions: self.getRequestOptions(),
resolveRelative: self.doesResolveRelative(),
preferEmbedded: self.doesPreferEmbeddedResources(),
startUrl: self.startUrl,
step : {
url: self.startUrl,
index: 0,
},
templateParameters: self.getTemplateParameters(),
};
traversalState.abortTraversal = abortTraversal.bind(traversalState);
if (self.continuation) {
traversalState.continuation = self.continuation;
traversalState.step = self.continuation.step;
self.continuation = null;
}
return traversalState;
}
function wrapForContinue(self, t, callback, firstTraversalAction) {
return function(err, result) {
if (err) { return callback(err); }
return callback(null, result, {
continue: function() {
if (!t) {
throw createError('No traversal state to continue from.',
errors.InvalidStateError);
}
log.debug('> continuing finished traversal process');
self.continuation = {
step: t.step,
action: firstTraversalAction,
};
self.continuation.step.index = 0;
initFromTraversalState(self, t);
return self;
},
});
};
}
/*
* Copy configuration from traversal state to builder instance to
* prepare for next traversal process.
*/
function initFromTraversalState(self, t) {
self.aborted = false;
self.adapter = t.adapter;
self.body = t.body;
self.callbackHasBeenCalledAfterAbort = false;
self.autoHeaders = t.autoHeaders;
self.contentNegotiation = t.contentNegotiation;
self.rawPayload = t.rawPayload;
self.convertResponseToObjectFlag = t.convertResponseToObject;
self.links = [];
self.jsonParser = t.jsonParser;
self.requestModuleInstance = t.requestModuleInstance,
self.requestOptions = t.requestOptions,
self.resolveRelativeFlag = t.resolveRelative;
self.preferEmbedded = t.preferEmbedded;
self.startUrl = t.startUrl;
self.templateParameters = t.templateParameters;
}
function cloneArrayOrObject(thing) {
if (util.isArray(thing)) {
return shallowCloneArray(thing);
} else if (typeof thing === 'object') {
return deepCloneObject(thing);
} else {
return thing;
}
}
function deepCloneObject(object) {
return mergeRecursive(null, object);
}
function shallowCloneArray(array) {
if (!array) {
return array;
}
return array.slice(0);
}
module.exports = Builder;