UNPKG

traverson

Version:

Hypermedia API/HATEOAS client for Node.js and the browser

762 lines (690 loc) 26.2 kB
'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;