lib/http-transport.js

/**
 * @module http-transport
 */
'use strict';

/*jshint -W079 */// Suppress warning about redefiniton of `Promise`
var Promise = require( 'es6-promise' ).Promise;

var agent = require( 'superagent' );
var parseLinkHeader = require( 'li' ).parse;
var url = require( 'url' );

var WPRequest = require( './constructors/wp-request' );
var checkMethodSupport = require( './util/check-method-support' );
var extend = require( 'node.extend' );
var objectReduce = require( './util/object-reduce' );
var isEmptyObject = require( './util/is-empty-object' );

/**
 * Set any provided headers on the outgoing request object. Runs after _auth.
 *
 * @method _setHeaders
 * @private
 * @param {Object} request A superagent request object
 * @param {Object} options A WPRequest _options object
 * @param {Object} A superagent request object, with any available headers set
 */
function _setHeaders( request, options ) {
	// If there's no headers, do nothing
	if ( ! options.headers ) {
		return request;
	}

	return objectReduce( options.headers, function( request, value, key ) {
		return request.set( key, value );
	}, request );
}

/**
 * Conditionally set basic authentication on a server request object.
 *
 * @method _auth
 * @private
 * @param {Object} request A superagent request object
 * @param {Object} options A WPRequest _options object
 * @param {Boolean} forceAuthentication whether to force authentication on the request
 * @param {Object} A superagent request object, conditionally configured to use basic auth
 */
function _auth( request, options, forceAuthentication ) {
	// If we're not supposed to authenticate, don't even start
	if ( ! forceAuthentication && ! options.auth && ! options.nonce ) {
		return request;
	}

	// Enable nonce in options for Cookie authentication http://wp-api.org/guides/authentication.html
	if ( options.nonce ) {
		request.set( 'X-WP-Nonce', options.nonce );
		return request;
	}

	// Retrieve the username & password from the request options if they weren't provided
	var username = username || options.username;
	var password = password || options.password;

	// If no username or no password, can't authenticate
	if ( ! username || ! password ) {
		return request;
	}

	// Can authenticate: set basic auth parameters on the request
	return request.auth( username, password );
}

// Pagination-Related Helpers
// ==========================

/**
 * Combine the API endpoint root URI and link URI into a valid request URL.
 * Endpoints are generally a full path to the JSON API's root endpoint, such
 * as `website.com/wp-json`: the link headers, however, are returned as root-
 * relative paths. Concatenating these would generate a URL such as
 * `website.com/wp-json/wp-json/posts?page=2`: we must intelligently merge the
 * URI strings in order to generate a valid new request URL.
 *
 * @private
 * @param endpoint {String} The endpoint URL for the REST API root
 * @param linkPath {String} A root-relative link path to an API request
 * @returns {String} The full URL path to the provided link
 */
function mergeUrl( endpoint, linkPath ) {
	var request = url.parse( endpoint );
	linkPath = url.parse( linkPath, true );

	// Overwrite relevant request URL object properties with the link's values:
	// Setting these three values from the link will ensure proper URL generation
	request.query = linkPath.query;
	request.search = linkPath.search;
	request.pathname = linkPath.pathname;

	// Reassemble and return the merged URL
	return url.format( request );
}

/**
 * Extract the body property from the superagent response, or else try to parse
 * the response text to get a JSON object.
 *
 * @private
 * @param {Object} response      The response object from the HTTP request
 * @param {String} response.text The response content as text
 * @param {Object} response.body The response content as a JS object
 * @returns {Object} The response content as a JS object
 */
function extractResponseBody( response ) {
	var responseBody = response.body;
	if ( isEmptyObject( responseBody ) && response.type === 'text/html' ) {
		// Response may have come back as HTML due to caching plugin; try to parse
		// the response text into JSON
		try {
			responseBody = JSON.parse( response.text );
		} catch ( e ) {
			// Swallow errors, it's OK to fall back to returning the body
		}
	}
	return responseBody;
}

/**
 * If the response is not paged, return the body as-is. If pagination
 * information is present in the response headers, parse those headers into
 * a custom `_paging` property on the response body. `_paging` contains links
 * to the previous and next pages in the collection, as well as metadata
 * about the size and number of pages in the collection.
 *
 * The structure of the `_paging` property is as follows:
 *
 * - `total` {Integer} The total number of records in the collection
 * - `totalPages` {Integer} The number of pages available
 * - `links` {Object} The parsed "links" headers, separated into individual URI strings
 * - `next` {WPRequest} A WPRequest object bound to the "next" page (if page exists)
 * - `prev` {WPRequest} A WPRequest object bound to the "previous" page (if page exists)
 *
 * @private
 * @param {Object} result           The response object from the HTTP request
 * @param {Object} options          The options hash from the original request
 * @param {String} options.endpoint The base URL of the requested API endpoint
 * @param {Object} httpTransport    The HTTP transport object used by the original request
 * @returns {Object} The pagination metadata object for this HTTP request, or else null
 */
function createPaginationObject( result, options, httpTransport ) {
	var _paging = null;

	if ( ! result.headers || ! result.headers[ 'x-wp-totalpages' ] ) {
		// No headers: return as-is
		return _paging;
	}

	var totalPages = result.headers[ 'x-wp-totalpages' ];

	if ( ! totalPages || totalPages === '0' ) {
		// No paging: return as-is
		return _paging;
	}

	// Decode the link header object
	var links = result.headers.link ? parseLinkHeader( result.headers.link ) : {};

	// Store pagination data from response headers on the response collection
	_paging = {
		total: result.headers[ 'x-wp-total' ],
		totalPages: totalPages,
		links: links
	};

	// Re-use any options from the original request, updating only the endpoint
	// (this ensures that request properties like authentication are preserved)
	var endpoint = options.endpoint;

	// Create a WPRequest instance pre-bound to the "next" page, if available
	if ( links.next ) {
		_paging.next = new WPRequest( extend( {}, options, {
			transport: httpTransport,
			endpoint: mergeUrl( endpoint, links.next )
		}));
	}

	// Create a WPRequest instance pre-bound to the "prev" page, if available
	if ( links.prev ) {
		_paging.prev = new WPRequest( extend( {}, options, {
			transport: httpTransport,
			endpoint: mergeUrl( endpoint, links.prev )
		}));
	}

	return _paging;
}

// HTTP-Related Helpers
// ====================

/**
 * Submit the provided superagent request object, invoke a callback (if it was
 * provided), and return a promise to the response from the HTTP request.
 *
 * @private
 * @param {Object} request A superagent request object
 * @param {Function} callback A callback function (optional)
 * @param {Function} transform A function to transform the result data
 * @returns {Promise} A promise to the superagent request
 */
function invokeAndPromisify( request, callback, transform ) {
	return new Promise(function( resolve, reject ) {
		// Fire off the result
		request.end(function( err, result ) {

			// Return the results as a promise
			if ( err || result.error ) {
				reject( err || result.error );
			} else {
				resolve( result );
			}
		});
	}).then( transform ).then(function( result ) {
		// If a node-style callback was provided, call it, but also return the
		// result value for use via the returned Promise
		if ( callback && typeof callback === 'function' ) {
			callback( null, result );
		}
		return result;
	}, function( err ) {
		// If the API provided an error object, it will be available within the
		// superagent response object as response.body (containing the response
		// JSON). If that object exists, it will have a .code property if it is
		// truly an API error (non-API errors will not have a .code).
		if ( err.response && err.response.body && err.response.body.code ) {
			// Forward API error response JSON on to the calling method: omit
			// all transport-specific (superagent-specific) properties
			err = err.response.body;
		}
		// If a callback was provided, ensure it is called with the error; otherwise
		// re-throw the error so that it can be handled by a Promise .catch or .then
		if ( callback && typeof callback === 'function' ) {
			callback( err );
		} else {
			throw err;
		}
	});
}

/**
 * Return the body of the request, augmented with pagination information if the
 * result is a paged collection.
 *
 * @private
 * @param {WPRequest} wpreq The WPRequest representing the returned HTTP response
 * @param {Object} result The results from the HTTP request
 * @returns {Object} The "body" property of the result, conditionally augmented with
 *                  pagination information if the result is a partial collection.
 */
function returnBody( wpreq, result ) {
	var body = extractResponseBody( result );
	var _paging = createPaginationObject( result, wpreq._options, wpreq.transport );
	if ( _paging ) {
		body._paging = _paging;
	}
	return body;
}

/**
 * Extract and return the headers property from a superagent response object
 *
 * @private
 * @param {Object} result The results from the HTTP request
 * @returns {Object} The "headers" property of the result
 */
function returnHeaders( result ) {
	return result.headers;
}

// HTTP Methods: Private HTTP-verb versions
// ========================================

/**
 * @method get
 * @async
 * @param {WPRequest} wpreq A WPRequest query object
 * @param {Function} [callback] A callback to invoke with the results of the GET request
 * @returns {Promise} A promise to the results of the HTTP request
 */
function _httpGet( wpreq, callback ) {
	checkMethodSupport( 'get', wpreq );
	var url = wpreq.toString();

	var request = _auth( agent.get( url ), wpreq._options );
	request = _setHeaders( request, wpreq._options );

	return invokeAndPromisify( request, callback, returnBody.bind( null, wpreq ) );
}

/**
 * Invoke an HTTP "POST" request against the provided endpoint
 * @method post
 * @async
 * @param {WPRequest} wpreq A WPRequest query object
 * @param {Object} data The data for the POST request
 * @param {Function} [callback] A callback to invoke with the results of the POST request
 * @returns {Promise} A promise to the results of the HTTP request
 */
function _httpPost( wpreq, data, callback ) {
	checkMethodSupport( 'post', wpreq );
	var url = wpreq.toString();
	data = data || {};
	var request = _auth( agent.post( url ), wpreq._options, true );
	request = _setHeaders( request, wpreq._options );

	if ( wpreq._attachment ) {
		// Data must be form-encoded alongside image attachment
		request = objectReduce( data, function( req, value, key ) {
			return req.field( key, value );
		}, request.attach( 'file', wpreq._attachment, wpreq._attachmentName ) );
	} else {
		request = request.send( data );
	}

	return invokeAndPromisify( request, callback, returnBody.bind( null, wpreq ) );
}

/**
 * @method put
 * @async
 * @param {WPRequest} wpreq A WPRequest query object
 * @param {Object} data The data for the PUT request
 * @param {Function} [callback] A callback to invoke with the results of the PUT request
 * @returns {Promise} A promise to the results of the HTTP request
 */
function _httpPut( wpreq, data, callback ) {
	checkMethodSupport( 'put', wpreq );
	var url = wpreq.toString();
	data = data || {};

	var request = _auth( agent.put( url ), wpreq._options, true ).send( data );
	request = _setHeaders( request, wpreq._options );

	return invokeAndPromisify( request, callback, returnBody.bind( null, wpreq ) );
}

/**
 * @method delete
 * @async
 * @param {WPRequest} wpreq A WPRequest query object
 * @param {Object} [data] Data to send along with the DELETE request
 * @param {Function} [callback] A callback to invoke with the results of the DELETE request
 * @returns {Promise} A promise to the results of the HTTP request
 */
function _httpDelete( wpreq, data, callback ) {
	if ( ! callback && typeof data === 'function' ) {
		callback = data;
		data = null;
	}
	checkMethodSupport( 'delete', wpreq );
	var url = wpreq.toString();
	var request = _auth( agent.del( url ), wpreq._options, true ).send( data );
	request = _setHeaders( request, wpreq._options );

	return invokeAndPromisify( request, callback, returnBody.bind( null, wpreq ) );
}

/**
 * @method head
 * @async
 * @param {WPRequest} wpreq A WPRequest query object
 * @param {Function} [callback] A callback to invoke with the results of the HEAD request
 * @returns {Promise} A promise to the header results of the HTTP request
 */
function _httpHead( wpreq, callback ) {
	checkMethodSupport( 'head', wpreq );
	var url = wpreq.toString();
	var request = _auth( agent.head( url ), wpreq._options );
	request = _setHeaders( request, wpreq._options );

	return invokeAndPromisify( request, callback, returnHeaders );
}

module.exports = {
	delete: _httpDelete,
	get: _httpGet,
	head: _httpHead,
	post: _httpPost,
	put: _httpPut
};