diff --git a/lib/constructors/wp-request.js b/lib/constructors/wp-request.js index 29d786d2..33b90b1a 100644 --- a/lib/constructors/wp-request.js +++ b/lib/constructors/wp-request.js @@ -5,11 +5,6 @@ * @beta */ -/*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 qs = require( 'qs' ); var _reduce = require( 'lodash.reduce' ); var _union = require( 'lodash.union' ); @@ -26,8 +21,10 @@ var keyValToObj = require( '../util/key-val-to-obj' ); * @constructor * @param {Object} options A hash of options for the WPRequest instance * @param {String} options.endpoint The endpoint URI for the invoking WP instance + * @param {Object} options.transport An object of http transport methods (get, post, etc) * @param {String} [options.username] A username for authenticating API requests * @param {String} [options.password] A password for authenticating API requests + * @param {String} [options.nonce] A WP nonce for use with cookie authentication */ function WPRequest( options ) { /** @@ -38,7 +35,28 @@ function WPRequest( options ) { * @private * @default {} */ - this._options = options || {}; + this._options = [ + // Whitelisted options keys + 'auth', + 'endpoint', + 'username', + 'password', + 'nonce' + ].reduce(function( localOptions, key ) { + if ( options && options[ key ] ) { + localOptions[ key ] = options[ key ]; + } + return localOptions; + }, {}); + + /** + * The HTTP transport methods (.get, .post, .put, .delete, .head) to use for this request + * + * @property transport + * @type {Object} + * @private + */ + this.transport = options && options.transport; /** * A hash of query parameters @@ -82,72 +100,6 @@ function identity( value ) { return value; } -/** - * Submit the provided superagent request object, invoke a callback (if it was - * provided), and return a promise to the response from the HTTP request. - * - * @param {Object} request A superagent request object - * @param {Function} callback A callback function (optional) - * @param {Function} transform A function to transform the result data - * @return {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 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. - * - * @method returnBody - * @private - * @param result {Object} The results from the HTTP request - * @return {Object} The "body" property of the result, conditionally augmented with - * pagination information if the result is a partial collection. - */ -function returnBody( result ) { - /* jshint validthis:true */ - var endpoint = this._options.endpoint; - return paginateResponse( result, endpoint ).body; -} - -/** - * Extract and return the headers property from a superagent response object - * - * @param {Object} result The results from the HTTP request - * @return {Object} The "headers" property of the result - */ -function returnHeaders( result ) { - return result.headers; -} - /** * Process arrays of taxonomy terms into query parameters. * All terms listed in the arrays will be required (AND behavior). @@ -248,97 +200,8 @@ function validatePathLevel( levelDefinitions, levelContents ) { } } -// 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. - * - * @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 ); -} - -/** - * 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) - * - * @param result {Object} The response object from the HTTP request - * @param endpoint {String} The base URL of the requested API endpoint - * @returns {Object} The body of the HTTP request, conditionally augmented with - * pagination metadata - */ -function paginateResponse( result, endpoint ) { - if ( ! result.headers || ! result.headers[ 'x-wp-totalpages' ] ) { - // No headers: return as-is - return result; - } - - var totalPages = result.headers[ 'x-wp-totalpages' ]; - - if ( ! totalPages || totalPages === '0' ) { - // No paging: return as-is - return result; - } - - // Decode the link header object - var links = result.headers.link ? parseLinkHeader( result.headers.link ) : {}; - - // Store pagination data from response headers on the response collection - result.body._paging = { - total: result.headers[ 'x-wp-total' ], - totalPages: totalPages, - links: links - }; - - // Create a WPRequest instance pre-bound to the "next" page, if available - if ( links.next ) { - result.body._paging.next = new WPRequest({ - endpoint: mergeUrl( endpoint, links.next ) - }); - } - - // Create a WPRequest instance pre-bound to the "prev" page, if available - if ( links.prev ) { - result.body._paging.prev = new WPRequest({ - endpoint: mergeUrl( endpoint, links.prev ) - }); - } - - return result; -} - -// Prototype Methods -// ================= +// (Semi-)Private Prototype Methods +// ================================ /** * Process the endpoint query's filter objects into a valid query string. @@ -374,6 +237,61 @@ WPRequest.prototype._renderQuery = function() { return ( queryString === '' ) ? '' : '?' + queryString; }; +/** + * Validate & assemble a path string from the request object's _path + * + * @private + * + * @method _renderPath + * @return {String} The rendered path + */ +WPRequest.prototype._renderPath = function() { + // Call validatePath: if the provided path components are not well-formed, + // an error will be thrown + this.validatePath(); + + var pathParts = this._path; + var orderedPathParts = Object.keys( pathParts ) + .sort(function( a, b ) { + var intA = parseInt( a, 10 ); + var intB = parseInt( b, 10 ); + return intA - intB; + }) + .map(function( pathPartKey ) { + return pathParts[ pathPartKey ]; + }); + + // Combine all parts of the path together, filtered to omit any components + // that are unspecified or empty strings, to create the full path template + var path = [ + this._namespace + ].concat( orderedPathParts ).filter( identity ).join( '/' ); + + return path; +}; + +// Public Prototype Methods +// ======================== + +/** + * Parse the request into a WordPress API request URI string + * + * @method toString + * @return {String} The URI for the HTTP request to be sent + */ +WPRequest.prototype.toString = function() { + // Render the path to a string + var path = this._renderPath(); + + // Render the query string + var queryStr = this._renderQuery(); + + return this._options.endpoint + path + queryStr; +}; + +/** @deprecated Use .toString() */ +WPRequest.prototype._renderURI = WPRequest.prototype.toString; + /** * Set a component of the resource URL itself (as opposed to a query parameter) * @@ -554,115 +472,6 @@ WPRequest.prototype.embed = function() { // HTTP Transport Prototype Methods // ================================ -/** - * Verify that the current request object supports a given HTTP verb - * - * @private - * - * @method _checkMethodSupport - * @param {String} method An HTTP method to check ('get', 'post', etc) - * @return true iff the method is within this._supportedMethods - */ -WPRequest.prototype._checkMethodSupport = function( method ) { - if ( this._supportedMethods.indexOf( method.toLowerCase() ) === -1 ) { - throw new Error( - 'Unsupported method; supported methods are: ' + - this._supportedMethods.join( ', ' ) - ); - } - - return true; -}; - -/** - * Validate & assemble a path string from the request object's _path - * - * @private - * - * @method _renderPath - * @return {String} The rendered path - */ -WPRequest.prototype._renderPath = function() { - // Call validatePath: if the provided path components are not well-formed, - // an error will be thrown - this.validatePath(); - - var pathParts = this._path; - var orderedPathParts = Object.keys( pathParts ) - .sort(function( a, b ) { - var intA = parseInt( a, 10 ); - var intB = parseInt( b, 10 ); - return intA - intB; - }) - .map(function( pathPartKey ) { - return pathParts[ pathPartKey ]; - }); - - // Combine all parts of the path together, filtered to omit any components - // that are unspecified or empty strings, to create the full path template - var path = [ - this._namespace - ].concat( orderedPathParts ).filter( identity ).join( '/' ); - - return path; -}; - -/** - * Conditionally set basic authentication on a server request object - * - * @method _auth - * @private - * @param {Object} request A superagent request object - * @param {Boolean} forceAuthentication whether to force authentication on the request - * @param {Object} A superagent request object, conditionally configured to use basic auth - */ -WPRequest.prototype._auth = function( request, forceAuthentication ) { - // If we're not supposed to authenticate, don't even start - if ( ! forceAuthentication && ! this._options.auth && ! this._options.nonce ) { - return request; - } - - // Enable nonce in options for Cookie authentication http://wp-api.org/guides/authentication.html - if ( this._options.nonce ) { - request.set( 'X-WP-Nonce', this._options.nonce ); - return request; - } - - // Retrieve the username & password from the request options if they weren't provided - var username = username || this._options.username; - var password = password || this._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 ); -}; - -// Non-chaining public methods -// =========================== - -/** - * Parse the request into a WordPress API request URI string - * - * @method toString - * @return {String} The URI for the HTTP request to be sent - */ -WPRequest.prototype.toString = function() { - // Render the path to a string - var path = this._renderPath(); - - // Render the query string - var queryStr = this._renderQuery(); - - return this._options.endpoint + path + queryStr; -}; - -/** @deprecated Use .toString() */ -WPRequest.prototype._renderURI = WPRequest.prototype.toString; - // Chaining methods // ================ @@ -779,143 +588,47 @@ WPRequest.prototype.file = function( file, name ) { return this; }; -// HTTP Methods: Private HTTP-verb versions -// ======================================== - -/** - * @method _httpGet - * @async - * @private - * @param {Function} [callback] A callback to invoke with the results of the GET request - * @return {Promise} A promise to the results of the HTTP request - */ -WPRequest.prototype._httpGet = function( callback ) { - this._checkMethodSupport( 'get' ); - var url = this.toString(); - - var request = this._auth( agent.get( url ) ); - - return invokeAndPromisify( request, callback, returnBody.bind( this ) ); -}; - -/** - * Invoke an HTTP "POST" request against the provided endpoint - * @method _httpPost - * @async - * @private - * @param {Object} data The data for the POST request - * @param {Function} [callback] A callback to invoke with the results of the POST request - * @return {Promise} A promise to the results of the HTTP request - */ -WPRequest.prototype._httpPost = function( data, callback ) { - this._checkMethodSupport( 'post' ); - var url = this.toString(); - data = data || {}; - var request = this._auth( agent.post( url ), true ); - - if ( this._attachment ) { - // Data must be form-encoded alongside image attachment - request = _reduce( data, function( req, value, key ) { - return req.field( key, value ); - }, request.attach( 'file', this._attachment, this._attachmentName ) ); - } else { - request = request.send( data ); - } - - return invokeAndPromisify( request, callback, returnBody.bind( this ) ); -}; - -/** - * @method _httpPut - * @async - * @private - * @param {Object} data The data for the PUT request - * @param {Function} [callback] A callback to invoke with the results of the PUT request - * @return {Promise} A promise to the results of the HTTP request - */ -WPRequest.prototype._httpPut = function( data, callback ) { - this._checkMethodSupport( 'put' ); - var url = this.toString(); - data = data || {}; - - var request = this._auth( agent.put( url ), true ).send( data ); - - return invokeAndPromisify( request, callback, returnBody.bind( this ) ); -}; - -/** - * @method _httpDelete - * @async - * @private - * @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 - * @return {Promise} A promise to the results of the HTTP request - */ -WPRequest.prototype._httpDelete = function( data, callback ) { - if ( ! callback && typeof data === 'function' ) { - callback = data; - data = null; - } - this._checkMethodSupport( 'delete' ); - var url = this.toString(); - var request = this._auth( agent.del( url ), true ).send( data ); - - return invokeAndPromisify( request, callback, returnBody.bind( this ) ); -}; - -/** - * @method _httpHead - * @async - * @private - * @param {Function} [callback] A callback to invoke with the results of the HEAD request - * @return {Promise} A promise to the header results of the HTTP request - */ -WPRequest.prototype._httpHead = function( callback ) { - this._checkMethodSupport( 'head' ); - var url = this.toString(); - var request = this._auth( agent.head( url ) ); - - return invokeAndPromisify( request, callback, returnHeaders ); -}; - // HTTP Methods: Public Interface // ============================== /** @deprecated Use .create() */ -WPRequest.prototype.post = function( data, callback ) { - return this._httpPost( data, callback ); +WPRequest.prototype.post = function() { + return this.create.apply( this, arguments ); }; /** @deprecated Use .update() */ -WPRequest.prototype.put = function( data, callback ) { - return this._httpPut( data, callback ); +WPRequest.prototype.put = function() { + return this.update.apply( this, arguments ); }; /** + * Get (download the data for) the specified resource + * * @method get * @async * @param {Function} [callback] A callback to invoke with the results of the GET request * @return {Promise} A promise to the results of the HTTP request */ WPRequest.prototype.get = function( callback ) { - return this._httpGet( callback ); + return this.transport.get( this, callback ); }; /** - * Create a HEAD request against a site + * Get the headers for the specified resource + * * @method headers * @async * @param {Function} [callback] A callback to invoke with the results of the HEAD request * @return {Promise} A promise to the header results of the HTTP request */ WPRequest.prototype.headers = function( callback ) { - return this._httpHead( callback ); + return this.transport.head( this, callback ); }; /** - * Invoke an HTTP "POST" request against the provided endpoint + * Create the specified resource with the provided data * - * This is the public interface creating for POST requests + * This is the public interface for creating POST requests * * @method create * @async @@ -924,11 +637,15 @@ WPRequest.prototype.headers = function( callback ) { * @return {Promise} A promise to the results of the HTTP request */ WPRequest.prototype.create = function( data, callback ) { - return this._httpPost( data, callback ); + return this.transport.post( this, data, callback ); }; /** - * @method _httpPut + * Update the specified resource with the provided data + * + * This is the public interface for creating PUT requests + * + * @method update * @async * @private * @param {Object} data The data for the PUT request @@ -936,10 +653,12 @@ WPRequest.prototype.create = function( data, callback ) { * @return {Promise} A promise to the results of the HTTP request */ WPRequest.prototype.update = function( data, callback ) { - return this._httpPut( data, callback ); + return this.transport.put( this, data, callback ); }; /** + * Delete the specified resource + * * @method delete * @async * @param {Object} [data] Data to send along with the DELETE request @@ -947,7 +666,7 @@ WPRequest.prototype.update = function( data, callback ) { * @return {Promise} A promise to the results of the HTTP request */ WPRequest.prototype.delete = function( data, callback ) { - return this._httpDelete( data, callback ); + return this.transport.delete( this, data, callback ); }; /** @@ -960,7 +679,7 @@ WPRequest.prototype.delete = function( data, callback ) { * @return {Promise} A promise to the results of the HTTP request */ WPRequest.prototype.then = function( successCallback, failureCallback ) { - return this._httpGet().then( successCallback, failureCallback ); + return this.transport.get( this ).then( successCallback, failureCallback ); }; module.exports = WPRequest; diff --git a/lib/endpoint-factories.js b/lib/endpoint-factories.js index 348111cf..fbe81a3c 100644 --- a/lib/endpoint-factories.js +++ b/lib/endpoint-factories.js @@ -36,9 +36,7 @@ function generateEndpointFactories( routesByNamespace ) { // "handler" object is now fully prepared; create the factory method that // will instantiate and return a handler instance handlers[ resource ] = function( options ) { - options = options || {}; - options = extend( options, this._options ); - return new EndpointRequest( options ); + return new EndpointRequest( extend( {}, this._options, options ) ); }; // Expose the constructor as a property on the factory function, so that diff --git a/lib/http-transport.js b/lib/http-transport.js new file mode 100644 index 00000000..448b5bc4 --- /dev/null +++ b/lib/http-transport.js @@ -0,0 +1,323 @@ +'use strict'; +/** + * @module http-transport + */ + +/*jshint -W079 */// Suppress warning about redefiniton of `Promise` +var Promise = require( 'es6-promise' ).Promise; + +var _reduce = require( 'lodash.reduce' ); +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' ); + +/** + * 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. + * + * @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 ); +} + +/** + * 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) + * + * @param result {Object} The response object from the HTTP request + * @param endpoint {String} The base URL of the requested API endpoint + * @returns {Object} The body of the HTTP request, conditionally augmented with + * pagination metadata + */ +function paginateResponse( result, endpoint, httpTransport ) { + if ( ! result.headers || ! result.headers[ 'x-wp-totalpages' ] ) { + // No headers: return as-is + return result; + } + + var totalPages = result.headers[ 'x-wp-totalpages' ]; + + if ( ! totalPages || totalPages === '0' ) { + // No paging: return as-is + return result; + } + + // Decode the link header object + var links = result.headers.link ? parseLinkHeader( result.headers.link ) : {}; + + // Store pagination data from response headers on the response collection + result.body._paging = { + total: result.headers[ 'x-wp-total' ], + totalPages: totalPages, + links: links + }; + + // Create a WPRequest instance pre-bound to the "next" page, if available + if ( links.next ) { + result.body._paging.next = new WPRequest({ + transport: httpTransport, + endpoint: mergeUrl( endpoint, links.next ) + }); + } + + // Create a WPRequest instance pre-bound to the "prev" page, if available + if ( links.prev ) { + result.body._paging.prev = new WPRequest({ + transport: httpTransport, + endpoint: mergeUrl( endpoint, links.prev ) + }); + } + + return result; +} + +// 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. + * + * @param {Object} request A superagent request object + * @param {Function} callback A callback function (optional) + * @param {Function} transform A function to transform the result data + * @return {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 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. + * + * @method returnBody + * @private + * @param {WPRequest} wpquery The WPRequest representing the returned HTTP response + * @param result {Object} The results from the HTTP request + * @return {Object} The "body" property of the result, conditionally augmented with + * pagination information if the result is a partial collection. + */ +function returnBody( wpquery, result ) { + var endpoint = wpquery._options.endpoint; + var httpTransport = wpquery.transport; + return paginateResponse( result, endpoint, httpTransport ).body; +} + +/** + * Extract and return the headers property from a superagent response object + * + * @param {Object} result The results from the HTTP request + * @return {Object} The "headers" property of the result + */ +function returnHeaders( result ) { + return result.headers; +} + +// HTTP Methods: Private HTTP-verb versions +// ======================================== + +/** + * @method _httpGet + * @async + * @private + * @param {WPRequest} wpquery A WPRequest query object + * @param {Function} [callback] A callback to invoke with the results of the GET request + * @return {Promise} A promise to the results of the HTTP request + */ +function _httpGet( wpquery, callback ) { + checkMethodSupport( 'get', wpquery ); + var url = wpquery.toString(); + + var request = _auth( agent.get( url ), wpquery._options ); + + return invokeAndPromisify( request, callback, returnBody.bind( null, wpquery ) ); +} + +/** + * Invoke an HTTP "POST" request against the provided endpoint + * @method _httpPost + * @async + * @private + * @param {WPRequest} wpquery 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 + * @return {Promise} A promise to the results of the HTTP request + */ +function _httpPost( wpquery, data, callback ) { + checkMethodSupport( 'post', wpquery ); + var url = wpquery.toString(); + data = data || {}; + var request = _auth( agent.post( url ), wpquery._options, true ); + + if ( wpquery._attachment ) { + // Data must be form-encoded alongside image attachment + request = _reduce( data, function( req, value, key ) { + return req.field( key, value ); + }, request.attach( 'file', wpquery._attachment, wpquery._attachmentName ) ); + } else { + request = request.send( data ); + } + + return invokeAndPromisify( request, callback, returnBody.bind( null, wpquery ) ); +} + +/** + * @method _httpPut + * @async + * @private + * @param {WPRequest} wpquery 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 + * @return {Promise} A promise to the results of the HTTP request + */ +function _httpPut( wpquery, data, callback ) { + checkMethodSupport( 'put', wpquery ); + var url = wpquery.toString(); + data = data || {}; + + var request = _auth( agent.put( url ), wpquery._options, true ).send( data ); + + return invokeAndPromisify( request, callback, returnBody.bind( null, wpquery ) ); +} + +/** + * @method _httpDelete + * @async + * @private + * @param {WPRequest} wpquery 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 + * @return {Promise} A promise to the results of the HTTP request + */ +function _httpDelete( wpquery, data, callback ) { + if ( ! callback && typeof data === 'function' ) { + callback = data; + data = null; + } + checkMethodSupport( 'delete', wpquery ); + var url = wpquery.toString(); + var request = _auth( agent.del( url ), wpquery._options, true ).send( data ); + + return invokeAndPromisify( request, callback, returnBody.bind( null, wpquery ) ); +} + +/** + * @method _httpHead + * @async + * @private + * @param {WPRequest} wpquery A WPRequest query object + * @param {Function} [callback] A callback to invoke with the results of the HEAD request + * @return {Promise} A promise to the header results of the HTTP request + */ +function _httpHead( wpquery, callback ) { + checkMethodSupport( 'head', wpquery ); + var url = wpquery.toString(); + var request = _auth( agent.head( url ), wpquery._options ); + + return invokeAndPromisify( request, callback, returnHeaders ); +} + +module.exports = { + delete: _httpDelete, + get: _httpGet, + head: _httpHead, + post: _httpPost, + put: _httpPut +}; diff --git a/lib/util/check-method-support.js b/lib/util/check-method-support.js new file mode 100644 index 00000000..90d84714 --- /dev/null +++ b/lib/util/check-method-support.js @@ -0,0 +1,19 @@ +'use strict'; + +/** + * Verify that a specific HTTP method is supported by the provided WPRequest + * + * @param {String} method An HTTP method to check ('get', 'post', etc) + * @param {WPRequest} request A WPRequest object with a _supportedMethods array + * @return true iff the method is within request._supportedMethods + */ +module.exports = function( method, request ) { + if ( request._supportedMethods.indexOf( method.toLowerCase() ) === -1 ) { + throw new Error( + 'Unsupported method; supported methods are: ' + + request._supportedMethods.join( ', ' ) + ); + } + + return true; +}; diff --git a/tests/integration/custom-http-transport.js b/tests/integration/custom-http-transport.js new file mode 100644 index 00000000..404f9f38 --- /dev/null +++ b/tests/integration/custom-http-transport.js @@ -0,0 +1,102 @@ +'use strict'; +var chai = require( 'chai' ); +var sinon = require( 'sinon' ); +chai.use( require( 'sinon-chai' ) ); +// Variable to use as our "success token" in promise assertions +var SUCCESS = 'success'; +// Chai-as-promised and the `expect( prom ).to.eventually.equal( SUCCESS ) is +// used to ensure that the assertions running within the promise chains are +// actually run. +chai.use( require( 'chai-as-promised' ) ); +var expect = chai.expect; + +/*jshint -W079 */// Suppress warning about redefiniton of `Promise` +var Promise = require( 'es6-promise' ).Promise; + +var WP = require( '../../' ); + +var httpTransport = require( '../../lib/http-transport' ); + +var credentials = require( './helpers/constants' ).credentials; + +describe( 'integration: custom HTTP transport methods', function() { + var wp; + var id; + var cache; + var cachingGet; + + beforeEach(function() { + cache = {}; + cachingGet = sinon.spy(function( wpquery, cb ) { + var result = cache[ wpquery ]; + // If a cache hit is found, return it via the same callback/promise + // signature as the default transport method + if ( result ) { + if ( cb && typeof cb === 'function' ) { + cb( null, result ); + } + return Promise.resolve( result ); + } + + // Delegate to default transport if no cached data was found + return WP.transport.get( wpquery, cb ).then(function( result ) { + cache[ wpquery ] = result; + return result; + }); + }); + + return WP.site( 'http://wpapi.loc/wp-json' ) + .posts() + .perPage( 1 ) + .then(function( posts ) { + id = posts[ 0 ].id; + + // Set up our spy here so the request to get the ID isn't counted + sinon.spy( httpTransport, 'get' ); + }); + }); + + afterEach(function() { + httpTransport.get.restore(); + }); + + it( 'can be defined to e.g. use a cache when available', function() { + var query1; + var query2; + + wp = new WP({ + endpoint: 'http://wpapi.loc/wp-json', + transport: { + get: cachingGet + } + }).auth( credentials ); + + query1 = wp.posts().id( id ); + var prom = query1 + .get() + .then(function( result ) { + expect( result.id ).to.equal( id ); + expect( cachingGet.callCount ).to.equal( 1 ); + expect( httpTransport.get.callCount ).to.equal( 1 ); + expect( httpTransport.get ).to.have.been.calledWith( query1 ); + expect( result ).to.equal( cache[ 'http://wpapi.loc/wp-json/wp/v2/posts/' + id ] ); + }) + .then(function() { + query2 = wp.posts().id( id ); + return query2.get(); + }) + .then(function( result ) { + expect( cachingGet.callCount ).to.equal( 2 ); + expect( httpTransport.get.callCount ).to.equal( 1 ); + // sinon will try to use toString when comparing arguments in calledWith, + // so we mess with that method to properly demonstrate the inequality + query2.toString = function() {}; + expect( httpTransport.get ).not.to.have.been.calledWith( query2 ); + expect( result ).to.equal( cache[ 'http://wpapi.loc/wp-json/wp/v2/posts/' + id ] ); + return SUCCESS; + }); + + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + +}); diff --git a/tests/unit/lib/constructors/wp-request.js b/tests/unit/lib/constructors/wp-request.js index d579c98d..7f8c1a64 100644 --- a/tests/unit/lib/constructors/wp-request.js +++ b/tests/unit/lib/constructors/wp-request.js @@ -7,6 +7,7 @@ var sandbox = require( 'sandboxed-module' ); var WPRequest = require( '../../../../lib/constructors/wp-request' ); var filterMixins = require( '../../../../lib/mixins/filters' ); +var checkMethodSupport = require( '../../../../lib/util/check-method-support' ); describe( 'WPRequest', function() { @@ -24,11 +25,9 @@ describe( 'WPRequest', function() { it( 'should set any passed-in options', function() { request = new WPRequest({ - booleanProp: true, - strProp: 'Some string' + endpoint: '/custom-endpoint/' }); - expect( request._options.booleanProp ).to.be.true; - expect( request._options.strProp ).to.equal( 'Some string' ); + expect( request._options.endpoint ).to.equal( '/custom-endpoint/' ); }); it( 'should define a _supportedMethods array', function() { @@ -97,17 +96,17 @@ describe( 'WPRequest', function() { }); - describe( '_checkMethodSupport', function() { + describe( 'checkMethodSupport', function() { it( 'should return true when called with a supported method', function() { - expect( request._checkMethodSupport( 'get' ) ).to.equal( true ); + expect( checkMethodSupport( 'get', request ) ).to.equal( true ); }); it( 'should throw an error when called with an unsupported method', function() { request._supportedMethods = [ 'get' ]; expect(function() { - return request._checkMethodSupport( 'post' ); + checkMethodSupport( 'post', request ); }).to.throw(); }); @@ -337,7 +336,7 @@ describe( 'WPRequest', function() { }); // auth - describe( '._auth', function() { + describe.skip( '._auth', function() { var mockAgent; @@ -916,12 +915,12 @@ describe( 'WPRequest', function() { expect( request.post ).to.be.a( 'function' ); }); - it( 'proxies to ._httpPost', function() { - sinon.stub( request, '_httpPost' ); + it( 'proxies to .create', function() { + sinon.stub( request, 'create' ); function cb() {} - request.post( 'foo', cb ); - expect( request._httpPost ).to.have.been.calledWith( 'foo', cb ); - request._httpPost.restore(); + request.create( 'foo', cb ); + expect( request.create ).to.have.been.calledWith( 'foo', cb ); + request.create.restore(); }); }); @@ -933,12 +932,12 @@ describe( 'WPRequest', function() { expect( request.put ).to.be.a( 'function' ); }); - it( 'proxies to ._httpPut', function() { - sinon.stub( request, '_httpPut' ); + it( 'proxies to .update', function() { + sinon.stub( request, 'update' ); function cb() {} request.put( 'foo', cb ); - expect( request._httpPut ).to.have.been.calledWith( 'foo', cb ); - request._httpPut.restore(); + expect( request.update ).to.have.been.calledWith( 'foo', cb ); + request.update.restore(); }); }); diff --git a/tests/unit/lib/wp-register-route.js b/tests/unit/lib/wp-register-route.js index 67f1bd9a..45482ad5 100644 --- a/tests/unit/lib/wp-register-route.js +++ b/tests/unit/lib/wp-register-route.js @@ -4,6 +4,7 @@ var expect = chai.expect; var WPRequest = require( '../../../lib/constructors/wp-request' ); var registerRoute = require( '../../../lib/wp-register-route' ); +var checkMethodSupport = require( '../../../lib/util/check-method-support' ); describe( 'wp.registerRoute', function() { @@ -298,7 +299,7 @@ describe( 'wp.registerRoute', function() { [ 'get', 'post' ].forEach(function( method ) { it( method, function() { expect(function() { - handler.a( 1 ).b( 2 )._checkMethodSupport( method ); + checkMethodSupport( method, handler.a( 1 ).b( 2 ) ); }).not.to.throw(); }); }); @@ -310,7 +311,7 @@ describe( 'wp.registerRoute', function() { [ 'delete', 'put' ].forEach(function( method ) { it( method, function() { expect(function() { - handler.a( 1 ).b( 2 )._checkMethodSupport( method ); + checkMethodSupport( method, handler.a( 1 ).b( 2 ) ); }).to.throw(); }); }); @@ -318,7 +319,9 @@ describe( 'wp.registerRoute', function() { }); it( 'support "head" implicitly if "get" is whitelisted', function() { - expect(function() { handler.a( 1 ).b( 2 )._checkMethodSupport( 'head' ); }).not.to.throw(); + expect(function() { + checkMethodSupport( 'head', handler.a( 1 ).b( 2 ) ); + }).not.to.throw(); }); it( 'support "get" implicitly if "head" is whitelisted', function() { @@ -328,7 +331,9 @@ describe( 'wp.registerRoute', function() { handler = factory({ endpoint: '/' }); - expect(function() { handler.a( 1 ).b( 2 )._checkMethodSupport( 'head' ); }).not.to.throw(); + expect(function() { + checkMethodSupport( 'head', handler.a( 1 ).b( 2 ) ); + }).not.to.throw(); }); }); @@ -340,7 +345,7 @@ describe( 'wp.registerRoute', function() { [ 'get', 'post', 'head', 'put', 'delete' ].forEach(function( method ) { it( method, function() { expect(function() { - handler.a( 1 )._checkMethodSupport( method ); + checkMethodSupport( method, handler.a( 1 ) ); }).not.to.throw(); }); }); @@ -361,7 +366,9 @@ describe( 'wp.registerRoute', function() { }); it( 'is properly whitelisted', function() { - expect(function() { handler.a( 1 ).b( 2 )._checkMethodSupport( 'post' ); }).not.to.throw(); + expect(function() { + checkMethodSupport( 'post', handler.a( 1 ).b( 2 ) ); + }).not.to.throw(); }); describe( 'implicitly blacklists other method', function() { @@ -369,7 +376,7 @@ describe( 'wp.registerRoute', function() { [ 'get', 'head', 'delete', 'put' ].forEach(function( method ) { it( method, function() { expect(function() { - handler.a( 1 ).b( 2 )._checkMethodSupport( method ); + checkMethodSupport( method, handler.a( 1 ).b( 2 ) ); }).to.throw(); }); }); diff --git a/tests/unit/route-handlers/comments.js b/tests/unit/route-handlers/comments.js index 38ce04cd..e8d22b52 100644 --- a/tests/unit/route-handlers/comments.js +++ b/tests/unit/route-handlers/comments.js @@ -21,11 +21,9 @@ describe( 'wp.comments', function() { it( 'should set any passed-in options', function() { comments = site.comments({ - booleanProp: true, - strProp: 'Some string' + endpoint: '/custom-endpoint/' }); - expect( comments._options.booleanProp ).to.be.true; - expect( comments._options.strProp ).to.equal( 'Some string' ); + expect( comments._options.endpoint ).to.equal( '/custom-endpoint/' ); }); it( 'should initialize _options to the site defaults', function() { diff --git a/tests/unit/route-handlers/media.js b/tests/unit/route-handlers/media.js index f0752420..5dcec2eb 100644 --- a/tests/unit/route-handlers/media.js +++ b/tests/unit/route-handlers/media.js @@ -21,11 +21,9 @@ describe( 'wp.media', function() { it( 'should set any passed-in options', function() { media = site.media({ - booleanProp: true, - strProp: 'Some string' + endpoint: '/custom-endpoint/' }); - expect( media._options.booleanProp ).to.be.true; - expect( media._options.strProp ).to.equal( 'Some string' ); + expect( media._options.endpoint ).to.equal( '/custom-endpoint/' ); }); it( 'should initialize _options to the site defaults', function() { diff --git a/tests/unit/route-handlers/pages.js b/tests/unit/route-handlers/pages.js index 96b825bf..5c1ebf76 100644 --- a/tests/unit/route-handlers/pages.js +++ b/tests/unit/route-handlers/pages.js @@ -21,11 +21,9 @@ describe( 'wp.pages', function() { it( 'should set any passed-in options', function() { pages = site.pages({ - booleanProp: true, - strProp: 'Some string' + endpoint: '/custom-endpoint/' }); - expect( pages._options.booleanProp ).to.be.true; - expect( pages._options.strProp ).to.equal( 'Some string' ); + expect( pages._options.endpoint ).to.equal( '/custom-endpoint/' ); }); it( 'should initialize _options to the site defaults', function() { diff --git a/tests/unit/route-handlers/posts.js b/tests/unit/route-handlers/posts.js index cdb17954..61c04537 100644 --- a/tests/unit/route-handlers/posts.js +++ b/tests/unit/route-handlers/posts.js @@ -21,11 +21,9 @@ describe( 'wp.posts', function() { it( 'should set any passed-in options', function() { posts = site.posts({ - booleanProp: true, - strProp: 'Some string' + endpoint: '/custom-endpoint/' }); - expect( posts._options.booleanProp ).to.be.true; - expect( posts._options.strProp ).to.equal( 'Some string' ); + expect( posts._options.endpoint ).to.equal( '/custom-endpoint/' ); }); it( 'should initialize _options to the site defaults', function() { diff --git a/tests/unit/route-handlers/taxonomies.js b/tests/unit/route-handlers/taxonomies.js index 383fd576..5748adab 100644 --- a/tests/unit/route-handlers/taxonomies.js +++ b/tests/unit/route-handlers/taxonomies.js @@ -21,11 +21,9 @@ describe( 'wp.taxonomies', function() { it( 'should set any passed-in options', function() { taxonomies = site.taxonomies({ - booleanProp: true, - strProp: 'Some string' + endpoint: '/custom-endpoint/' }); - expect( taxonomies._options.booleanProp ).to.be.true; - expect( taxonomies._options.strProp ).to.equal( 'Some string' ); + expect( taxonomies._options.endpoint ).to.equal( '/custom-endpoint/' ); }); it( 'should initialize _options to the site defaults', function() { diff --git a/tests/unit/route-handlers/types.js b/tests/unit/route-handlers/types.js index acaa1662..b871b5d2 100644 --- a/tests/unit/route-handlers/types.js +++ b/tests/unit/route-handlers/types.js @@ -21,11 +21,9 @@ describe( 'wp.types', function() { it( 'should set any passed-in options', function() { types = site.types({ - booleanProp: true, - strProp: 'Some string' + endpoint: '/custom-endpoint/' }); - expect( types._options.booleanProp ).to.be.true; - expect( types._options.strProp ).to.equal( 'Some string' ); + expect( types._options.endpoint ).to.equal( '/custom-endpoint/' ); }); it( 'should initialize _options to the site defaults', function() { diff --git a/tests/unit/route-handlers/users.js b/tests/unit/route-handlers/users.js index 528625f6..632cdbbc 100644 --- a/tests/unit/route-handlers/users.js +++ b/tests/unit/route-handlers/users.js @@ -21,11 +21,9 @@ describe( 'wp.users', function() { it( 'should set any passed-in options', function() { users = site.users({ - booleanProp: true, - strProp: 'Some string' + endpoint: '/custom-endpoint/' }); - expect( users._options.booleanProp ).to.be.true; - expect( users._options.strProp ).to.equal( 'Some string' ); + expect( users._options.endpoint ).to.equal( '/custom-endpoint/' ); }); it( 'should initialize _options to the site defaults', function() { diff --git a/tests/unit/wp.js b/tests/unit/wp.js index 5cc99cd7..ab37483a 100644 --- a/tests/unit/wp.js +++ b/tests/unit/wp.js @@ -1,11 +1,17 @@ 'use strict'; -var expect = require( 'chai' ).expect; +var chai = require( 'chai' ); +var sinon = require( 'sinon' ); +chai.use( require( 'sinon-chai' ) ); +var expect = chai.expect; var WP = require( '../../' ); // Constructors, for use with instanceof checks var WPRequest = require( '../../lib/constructors/wp-request' ); +// HTTP transport, for stubbing +var httpTransport = require( '../../lib/http-transport' ); + describe( 'wp', function() { var site; @@ -55,9 +61,182 @@ describe( 'wp', function() { expect( wp._options.password ).to.equal( 'dostoyevsky' ); }); + describe( 'assigns default HTTP transport', function() { + + it( 'for GET requests', function() { + sinon.stub( httpTransport, 'get' ); + var site = new WP({ + endpoint: 'http://some.url.com/wp-json' + }); + var query = site.root( '' ); + query.get(); + expect( httpTransport.get ).to.have.been.calledWith( query ); + httpTransport.get.restore(); + }); + + it( 'for POST requests', function() { + sinon.stub( httpTransport, 'post' ); + var site = new WP({ + endpoint: 'http://some.url.com/wp-json' + }); + var query = site.root( '' ); + var data = {}; + query.create( data ); + expect( httpTransport.post ).to.have.been.calledWith( query, data ); + httpTransport.post.restore(); + }); + + it( 'for POST requests', function() { + sinon.stub( httpTransport, 'post' ); + var site = new WP({ + endpoint: 'http://some.url.com/wp-json' + }); + var query = site.root( '' ); + var data = {}; + query.create( data ); + expect( httpTransport.post ).to.have.been.calledWith( query, data ); + httpTransport.post.restore(); + }); + + it( 'for PUT requests', function() { + sinon.stub( httpTransport, 'put' ); + var site = new WP({ + endpoint: 'http://some.url.com/wp-json' + }); + var query = site.root( 'a-resource' ); + var data = {}; + query.update( data ); + expect( httpTransport.put ).to.have.been.calledWith( query, data ); + httpTransport.put.restore(); + }); + + it( 'for DELETE requests', function() { + sinon.stub( httpTransport, 'delete' ); + var site = new WP({ + endpoint: 'http://some.url.com/wp-json' + }); + var query = site.root( 'a-resource' ); + var data = { + force: true + }; + query.delete( data ); + expect( httpTransport.delete ).to.have.been.calledWith( query, data ); + httpTransport.delete.restore(); + }); + + }); + + describe( 'custom HTTP transport methods', function() { + + it( 'can be set for an individual HTTP action', function() { + sinon.stub( httpTransport, 'get' ); + var customGet = sinon.stub(); + var site = new WP({ + endpoint: 'http://some.url.com/wp-json', + transport: { + get: customGet + } + }); + var query = site.root( '' ); + query.get(); + expect( httpTransport.get ).not.to.have.been.called; + expect( customGet ).to.have.been.calledWith( query ); + httpTransport.get.restore(); + }); + + it( 'can extend the default HTTP transport methods', function() { + sinon.stub( httpTransport, 'get' ); + var customGet = sinon.spy(function() { + WP.transport.get.apply( null, arguments ); + }); + var site = new WP({ + endpoint: 'http://some.url.com/wp-json', + transport: { + get: customGet + } + }); + var query = site.root( '' ); + query.get(); + expect( customGet ).to.have.been.calledWith( query ); + expect( httpTransport.get ).to.have.been.calledWith( query ); + httpTransport.get.restore(); + }); + + it( 'can be set for multiple HTTP actions', function() { + sinon.stub( httpTransport, 'post' ); + sinon.stub( httpTransport, 'put' ); + var customPost = sinon.stub(); + var customPut = sinon.stub(); + var site = new WP({ + endpoint: 'http://some.url.com/wp-json', + transport: { + post: customPost, + put: customPut + } + }); + var query = site.root( 'a-resource' ); + var data = {}; + query.create( data ); + expect( httpTransport.post ).not.to.have.been.called; + expect( customPost ).to.have.been.calledWith( query, data ); + query.update( data ); + expect( httpTransport.put ).not.to.have.been.called; + expect( customPut ).to.have.been.calledWith( query, data ); + httpTransport.post.restore(); + httpTransport.put.restore(); + }); + + it( 'only apply to a specific WP instance', function() { + sinon.stub( httpTransport, 'get' ); + var customGet = sinon.stub(); + var site = new WP({ + endpoint: 'http://some.url.com/wp-json', + transport: { + get: customGet + } + }); + var site2 = new WP({ + endpoint: 'http://some.url.com/wp-json' + }); + expect( site ).not.to.equal( site2 ); + var query = site2.root( '' ); + query.get(); + expect( httpTransport.get ).to.have.been.calledWith( query ); + expect( customGet ).not.to.have.been.called; + httpTransport.get.restore(); + }); + + }); + + }); + + describe( '.transport constructor property', function() { + + it( 'is defined', function() { + expect( WP ).to.have.property( 'transport' ); + }); + + it( 'is an object', function() { + expect( WP.transport ).to.be.an( 'object' ); + }); + + it( 'has methods for each http transport action', function() { + expect( WP.transport.delete ).to.be.a( 'function' ); + expect( WP.transport.get ).to.be.a( 'function' ); + expect( WP.transport.head ).to.be.a( 'function' ); + expect( WP.transport.post ).to.be.a( 'function' ); + expect( WP.transport.put ).to.be.a( 'function' ); + }); + + it( 'is frozen (properties cannot be modified directly)', function() { + expect(function() { + WP.transport.get = function() {}; + }).to.throw(); + }); + }); - describe( '.site()', function() { + describe( '.site() constructor method', function() { it( 'is a function', function() { expect( WP ).to.have.property( 'site' ); @@ -93,350 +272,359 @@ describe( 'wp', function() { }); - describe( '.namespace()', function() { + describe( '.discover() constructor method', function() { it( 'is a function', function() { - expect( site ).to.have.property( 'namespace' ); - expect( site.namespace ).to.be.a( 'function' ); + expect( WP ).to.have.property( 'discover' ); + expect( WP.discover ).to.be.a( 'function' ); }); - it( 'returns a namespace object with relevant endpoint handler methods', function() { - var wpV2 = site.namespace( 'wp/v2' ); - // Spot check - expect( wpV2 ).to.be.an( 'object' ); - expect( wpV2 ).to.have.property( 'posts' ); - expect( wpV2.posts ).to.be.a( 'function' ); - expect( wpV2 ).to.have.property( 'comments' ); - expect( wpV2.comments ).to.be.a( 'function' ); - }); + }); - it( 'passes options from the parent WP instance to the namespaced handlers', function() { - site.auth( 'u', 'p' ); - var pages = site.namespace( 'wp/v2' ).pages(); - expect( pages._options ).to.be.an( 'object' ); - expect( pages._options ).to.have.property( 'username' ); - expect( pages._options.username ).to.equal( 'u' ); - expect( pages._options ).to.have.property( 'password' ); - expect( pages._options.password ).to.equal( 'p' ); - }); + describe( '.prototype', function() { - it( 'permits the namespace to be stored in a variable without disrupting options', function() { - site.auth( 'u', 'p' ); - var wpV2 = site.namespace( 'wp/v2' ); - var pages = wpV2.pages(); - expect( pages._options ).to.be.an( 'object' ); - expect( pages._options ).to.have.property( 'username' ); - expect( pages._options.username ).to.equal( 'u' ); - expect( pages._options ).to.have.property( 'password' ); - expect( pages._options.password ).to.equal( 'p' ); - }); + describe( '.namespace()', function() { - it( 'throws an error when provided no namespace', function() { - expect(function() { - site.namespace(); - }).to.throw(); - }); + it( 'is a function', function() { + expect( site ).to.have.property( 'namespace' ); + expect( site.namespace ).to.be.a( 'function' ); + }); - it( 'throws an error when provided an unregistered namespace', function() { - expect(function() { - site.namespace( 'foo/baz' ); - }).to.throw(); - }); + it( 'returns a namespace object with relevant endpoint handler methods', function() { + var wpV2 = site.namespace( 'wp/v2' ); + // Spot check + expect( wpV2 ).to.be.an( 'object' ); + expect( wpV2 ).to.have.property( 'posts' ); + expect( wpV2.posts ).to.be.a( 'function' ); + expect( wpV2 ).to.have.property( 'comments' ); + expect( wpV2.comments ).to.be.a( 'function' ); + }); - }); + it( 'passes options from the parent WP instance to the namespaced handlers', function() { + site.auth( 'u', 'p' ); + var pages = site.namespace( 'wp/v2' ).pages(); + expect( pages._options ).to.be.an( 'object' ); + expect( pages._options ).to.have.property( 'username' ); + expect( pages._options.username ).to.equal( 'u' ); + expect( pages._options ).to.have.property( 'password' ); + expect( pages._options.password ).to.equal( 'p' ); + }); - describe( '.bootstrap()', function() { + it( 'permits the namespace to be stored in a variable without disrupting options', function() { + site.auth( 'u', 'p' ); + var wpV2 = site.namespace( 'wp/v2' ); + var pages = wpV2.pages(); + expect( pages._options ).to.be.an( 'object' ); + expect( pages._options ).to.have.property( 'username' ); + expect( pages._options.username ).to.equal( 'u' ); + expect( pages._options ).to.have.property( 'password' ); + expect( pages._options.password ).to.equal( 'p' ); + }); - beforeEach(function() { - site.bootstrap({ - '/myplugin/v1/authors/(?P[\\w-]+)': { - namespace: 'myplugin/v1', - methods: [ 'GET', 'POST' ], - endpoints: [ { - methods: [ 'GET' ], - args: { - name: { required: false } - } - } ] - }, - '/wp/v2/customendpoint/(?P[\\w-]+)': { - namespace: 'wp/v2', - methods: [ 'GET', 'POST' ], - endpoints: [ { - methods: [ 'GET' ], - args: { - parent: { required: false } - } - } ] - } + it( 'throws an error when provided no namespace', function() { + expect(function() { + site.namespace(); + }).to.throw(); }); - }); - it( 'is a function', function() { - expect( site ).to.have.property( 'bootstrap' ); - expect( site.bootstrap ).to.be.a( 'function' ); - }); + it( 'throws an error when provided an unregistered namespace', function() { + expect(function() { + site.namespace( 'foo/baz' ); + }).to.throw(); + }); - it( 'is chainable', function() { - expect( site.bootstrap() ).to.equal( site ); }); - it( 'creates handlers for all provided route definitions', function() { - expect( site.namespace( 'myplugin/v1' ) ).to.be.an( 'object' ); - expect( site.namespace( 'myplugin/v1' ) ).to.have.property( 'authors' ); - expect( site.namespace( 'myplugin/v1' ).authors ).to.be.a( 'function' ); - expect( site.namespace( 'wp/v2' ) ).to.be.an( 'object' ); - expect( site.namespace( 'wp/v2' ) ).to.have.property( 'customendpoint' ); - expect( site.namespace( 'wp/v2' ).customendpoint ).to.be.a( 'function' ); - }); + describe( '.bootstrap()', function() { + + beforeEach(function() { + site.bootstrap({ + '/myplugin/v1/authors/(?P[\\w-]+)': { + namespace: 'myplugin/v1', + methods: [ 'GET', 'POST' ], + endpoints: [ { + methods: [ 'GET' ], + args: { + name: { required: false } + } + } ] + }, + '/wp/v2/customendpoint/(?P[\\w-]+)': { + namespace: 'wp/v2', + methods: [ 'GET', 'POST' ], + endpoints: [ { + methods: [ 'GET' ], + args: { + parent: { required: false } + } + } ] + } + }); + }); - it( 'properly assigns setter methods for detected path parts', function() { - var thingHandler = site.customendpoint(); - expect( thingHandler ).to.have.property( 'thing' ); - expect( thingHandler.thing ).to.be.a( 'function' ); - expect( thingHandler.thing( 'foobar' ).toString() ).to.equal( 'endpoint/url/wp/v2/customendpoint/foobar' ); - }); + it( 'is a function', function() { + expect( site ).to.have.property( 'bootstrap' ); + expect( site.bootstrap ).to.be.a( 'function' ); + }); - it( 'assigns any mixins for detected GET arguments for custom namespace handlers', function() { - var authorsHandler = site.namespace( 'myplugin/v1' ).authors(); - expect( authorsHandler ).to.have.property( 'name' ); - expect( authorsHandler ).not.to.have.ownProperty( 'name' ); - expect( authorsHandler.name ).to.be.a( 'function' ); - var customEndpoint = site.customendpoint(); - expect( customEndpoint ).to.have.property( 'parent' ); - expect( customEndpoint ).not.to.have.ownProperty( 'parent' ); - expect( customEndpoint.parent ).to.be.a( 'function' ); - }); + it( 'is chainable', function() { + expect( site.bootstrap() ).to.equal( site ); + }); - it( 'assigns handlers for wp/v2 routes to the instance object itself', function() { - expect( site ).to.have.property( 'customendpoint' ); - expect( site.customendpoint ).to.be.a( 'function' ); - expect( site.namespace( 'wp/v2' ).customendpoint ).to.equal( site.customendpoint ); - }); + it( 'creates handlers for all provided route definitions', function() { + expect( site.namespace( 'myplugin/v1' ) ).to.be.an( 'object' ); + expect( site.namespace( 'myplugin/v1' ) ).to.have.property( 'authors' ); + expect( site.namespace( 'myplugin/v1' ).authors ).to.be.a( 'function' ); + expect( site.namespace( 'wp/v2' ) ).to.be.an( 'object' ); + expect( site.namespace( 'wp/v2' ) ).to.have.property( 'customendpoint' ); + expect( site.namespace( 'wp/v2' ).customendpoint ).to.be.a( 'function' ); + }); - }); + it( 'properly assigns setter methods for detected path parts', function() { + var thingHandler = site.customendpoint(); + expect( thingHandler ).to.have.property( 'thing' ); + expect( thingHandler.thing ).to.be.a( 'function' ); + expect( thingHandler.thing( 'foobar' ).toString() ).to.equal( 'endpoint/url/wp/v2/customendpoint/foobar' ); + }); - describe( '.url()', function() { + it( 'assigns any mixins for detected GET arguments for custom namespace handlers', function() { + var authorsHandler = site.namespace( 'myplugin/v1' ).authors(); + expect( authorsHandler ).to.have.property( 'name' ); + expect( authorsHandler ).not.to.have.ownProperty( 'name' ); + expect( authorsHandler.name ).to.be.a( 'function' ); + var customEndpoint = site.customendpoint(); + expect( customEndpoint ).to.have.property( 'parent' ); + expect( customEndpoint ).not.to.have.ownProperty( 'parent' ); + expect( customEndpoint.parent ).to.be.a( 'function' ); + }); - it( 'is defined', function() { - expect( site ).to.have.property( 'url' ); - expect( site.url ).to.be.a( 'function' ); - }); + it( 'assigns handlers for wp/v2 routes to the instance object itself', function() { + expect( site ).to.have.property( 'customendpoint' ); + expect( site.customendpoint ).to.be.a( 'function' ); + expect( site.namespace( 'wp/v2' ).customendpoint ).to.equal( site.customendpoint ); + }); - it( 'creates a basic WPRequest object bound to the provided URL', function() { - var request = site.url( 'http://some.arbitrary.url' ); - expect( request instanceof WPRequest ).to.be.true; - expect( request._options.endpoint ).to.equal( 'http://some.arbitrary.url' ); }); - it( 'maps requests directly onto the provided URL', function() { - var request = site.url( 'http://some.url.com/wp-json?filter[name]=some-slug' ); - var path = request.toString(); - expect( path ).to.equal( 'http://some.url.com/wp-json?filter[name]=some-slug' ); - }); + describe( '.url()', function() { - it( 'inherits non-endpoint options from the parent WP instance', function() { - var wp = new WP({ - endpoint: 'http://website.com/', - identifier: 'some unique value' - }); - var request = wp.url( 'http://new-endpoint.com/' ); - expect( request._options ).to.have.property( 'endpoint' ); - expect( request._options.endpoint ).to.equal( 'http://new-endpoint.com/' ); - expect( request._options ).to.have.property( 'identifier' ); - expect( request._options.identifier ).to.equal( 'some unique value' ); - }); + it( 'is defined', function() { + expect( site ).to.have.property( 'url' ); + expect( site.url ).to.be.a( 'function' ); + }); - }); + it( 'creates a basic WPRequest object bound to the provided URL', function() { + var request = site.url( 'http://some.arbitrary.url' ); + expect( request instanceof WPRequest ).to.be.true; + expect( request._options.endpoint ).to.equal( 'http://some.arbitrary.url' ); + }); - describe( '.root()', function() { + it( 'maps requests directly onto the provided URL', function() { + var request = site.url( 'http://some.url.com/wp-json?filter[name]=some-slug' ); + var path = request.toString(); + expect( path ).to.equal( 'http://some.url.com/wp-json?filter[name]=some-slug' ); + }); - beforeEach(function() { - site = new WP({ endpoint: 'http://my.site.com/wp-json' }); - }); + it( 'inherits whitelisted non-endpoint options from the parent WP instance', function() { + var wp = new WP({ + endpoint: 'http://website.com/', + identifier: 'some unique value' + }); + var request = wp.url( 'http://new-endpoint.com/' ); + expect( request._options ).to.have.property( 'endpoint' ); + expect( request._options.endpoint ).to.equal( 'http://new-endpoint.com/' ); + expect( request._options ).not.to.have.property( 'identifier' ); + }); - it( 'is defined', function() { - expect( site ).to.have.property( 'root' ); - expect( site.root ).to.be.a( 'function' ); }); - it( 'creates a get request against the root endpoint', function() { - var request = site.root(); - expect( request.toString() ).to.equal( 'http://my.site.com/wp-json/' ); - }); + describe( '.root()', function() { - it( 'takes a "path" argument to query a root-relative path', function() { - var request = site.root( 'custom/endpoint' ); - expect( request.toString() ).to.equal( 'http://my.site.com/wp-json/custom/endpoint' ); - }); + beforeEach(function() { + site = new WP({ endpoint: 'http://my.site.com/wp-json' }); + }); - it( 'creates a WPRequest object', function() { - var pathRequest = site.root( 'some/collection/endpoint' ); - expect( pathRequest instanceof WPRequest ).to.be.true; - }); + it( 'is defined', function() { + expect( site ).to.have.property( 'root' ); + expect( site.root ).to.be.a( 'function' ); + }); - it( 'inherits options from the parent WP instance', function() { - var wp = new WP({ - endpoint: 'http://cat.website.com/', - customOption: 'best method ever' - }); - var request = wp.root( 'custom-path' ); - expect( request._options ).to.have.property( 'endpoint' ); - expect( request._options.endpoint ).to.equal( 'http://cat.website.com/' ); - expect( request._options ).to.have.property( 'customOption' ); - expect( request._options.customOption ).to.equal( 'best method ever' ); - }); + it( 'creates a get request against the root endpoint', function() { + var request = site.root(); + expect( request.toString() ).to.equal( 'http://my.site.com/wp-json/' ); + }); - }); + it( 'takes a "path" argument to query a root-relative path', function() { + var request = site.root( 'custom/endpoint' ); + expect( request.toString() ).to.equal( 'http://my.site.com/wp-json/custom/endpoint' ); + }); - describe( 'auth', function() { + it( 'creates a WPRequest object', function() { + var pathRequest = site.root( 'some/collection/endpoint' ); + expect( pathRequest instanceof WPRequest ).to.be.true; + }); - beforeEach(function() { - site = new WP({ endpoint: 'http://my.site.com/wp-json' }); - }); + it( 'inherits options from the parent WP instance', function() { + var wp = new WP({ + endpoint: 'http://cat.website.com/' + }); + var request = wp.root( 'custom-path' ); + expect( request._options ).to.have.property( 'endpoint' ); + expect( request._options.endpoint ).to.equal( 'http://cat.website.com/' ); + }); - it( 'is defined', function() { - expect( site ).to.have.property( 'auth' ); - expect( site.auth ).to.be.a( 'function' ); }); - it( 'sets the "auth" option to "true"', function() { - expect( site._options ).not.to.have.property( 'auth' ); - site.auth(); - expect( site._options ).to.have.property( 'auth' ); - expect( site._options.auth ).to.be.true; - }); + describe( '.auth()', function() { - it( 'sets the username and password when provided as strings', function() { - site.auth( 'user1', 'pass1' ); - expect( site._options ).to.have.property( 'username' ); - expect( site._options ).to.have.property( 'password' ); - expect( site._options.username ).to.equal( 'user1' ); - expect( site._options.password ).to.equal( 'pass1' ); - expect( site._options ).to.have.property( 'auth' ); - expect( site._options.auth ).to.be.true; - }); + beforeEach(function() { + site = new WP({ endpoint: 'http://my.site.com/wp-json' }); + }); - it( 'sets the username and password when provided in an object', function() { - site.auth({ - username: 'user1', - password: 'pass1' - }); - expect( site._options ).to.have.property( 'username' ); - expect( site._options ).to.have.property( 'password' ); - expect( site._options.username ).to.equal( 'user1' ); - expect( site._options.password ).to.equal( 'pass1' ); - expect( site._options ).to.have.property( 'auth' ); - expect( site._options.auth ).to.be.true; - }); + it( 'is defined', function() { + expect( site ).to.have.property( 'auth' ); + expect( site.auth ).to.be.a( 'function' ); + }); - it( 'can update previously-set usernames and passwords', function() { - site.auth({ - username: 'user1', - password: 'pass1' - }).auth({ - username: 'admin', - password: 'sandwich' - }); - expect( site._options ).to.have.property( 'username' ); - expect( site._options ).to.have.property( 'password' ); - expect( site._options.username ).to.equal( 'admin' ); - expect( site._options.password ).to.equal( 'sandwich' ); - expect( site._options ).to.have.property( 'auth' ); - expect( site._options.auth ).to.be.true; - }); + it( 'sets the "auth" option to "true"', function() { + expect( site._options ).not.to.have.property( 'auth' ); + site.auth(); + expect( site._options ).to.have.property( 'auth' ); + expect( site._options.auth ).to.be.true; + }); - it( 'sets the nonce when provided in an object', function() { - site.auth({ - nonce: 'somenonce' + it( 'sets the username and password when provided as strings', function() { + site.auth( 'user1', 'pass1' ); + expect( site._options ).to.have.property( 'username' ); + expect( site._options ).to.have.property( 'password' ); + expect( site._options.username ).to.equal( 'user1' ); + expect( site._options.password ).to.equal( 'pass1' ); + expect( site._options ).to.have.property( 'auth' ); + expect( site._options.auth ).to.be.true; + }); + + it( 'sets the username and password when provided in an object', function() { + site.auth({ + username: 'user1', + password: 'pass1' + }); + expect( site._options ).to.have.property( 'username' ); + expect( site._options ).to.have.property( 'password' ); + expect( site._options.username ).to.equal( 'user1' ); + expect( site._options.password ).to.equal( 'pass1' ); + expect( site._options ).to.have.property( 'auth' ); + expect( site._options.auth ).to.be.true; + }); + + it( 'can update previously-set usernames and passwords', function() { + site.auth({ + username: 'user1', + password: 'pass1' + }).auth({ + username: 'admin', + password: 'sandwich' + }); + expect( site._options ).to.have.property( 'username' ); + expect( site._options ).to.have.property( 'password' ); + expect( site._options.username ).to.equal( 'admin' ); + expect( site._options.password ).to.equal( 'sandwich' ); + expect( site._options ).to.have.property( 'auth' ); + expect( site._options.auth ).to.be.true; }); - expect( site._options ).to.have.property( 'nonce' ); - expect( site._options.nonce ).to.equal( 'somenonce' ); - expect( site._options ).to.have.property( 'auth' ); - expect( site._options.auth ).to.be.true; - }); - it( 'can update nonce credentials', function() { - site.auth({ - nonce: 'somenonce' - }).auth({ - nonce: 'refreshednonce' + it( 'sets the nonce when provided in an object', function() { + site.auth({ + nonce: 'somenonce' + }); + expect( site._options ).to.have.property( 'nonce' ); + expect( site._options.nonce ).to.equal( 'somenonce' ); + expect( site._options ).to.have.property( 'auth' ); + expect( site._options.auth ).to.be.true; }); - expect( site._options ).to.have.property( 'nonce' ); - expect( site._options.nonce ).to.equal( 'refreshednonce' ); - expect( site._options ).to.have.property( 'auth' ); - expect( site._options.auth ).to.be.true; + + it( 'can update nonce credentials', function() { + site.auth({ + nonce: 'somenonce' + }).auth({ + nonce: 'refreshednonce' + }); + expect( site._options ).to.have.property( 'nonce' ); + expect( site._options.nonce ).to.equal( 'refreshednonce' ); + expect( site._options ).to.have.property( 'auth' ); + expect( site._options.auth ).to.be.true; + }); + + it( 'passes authentication status to all subsequently-instantiated handlers', function() { + site.auth({ + username: 'user', + password: 'pass' + }); + var req = site.root( '' ); + expect( req ).to.have.property( '_options' ); + expect( req._options ).to.be.an( 'object' ); + expect( req._options ).to.have.property( 'username' ); + expect( req._options.username ).to.equal( 'user' ); + expect( req._options ).to.have.property( 'password' ); + expect( req._options.password ).to.equal( 'pass' ); + expect( req._options ).to.have.property( 'password' ); + expect( req._options.auth ).to.equal( true ); + }); + }); - it( 'passes authentication status to all subsequently-instantiated handlers', function() { - site.auth({ - username: 'user', - password: 'pass' - }); - var req = site.root( '' ); - expect( req ).to.have.property( '_options' ); - expect( req._options ).to.be.an( 'object' ); - expect( req._options ).to.have.property( 'username' ); - expect( req._options.username ).to.equal( 'user' ); - expect( req._options ).to.have.property( 'password' ); - expect( req._options.password ).to.equal( 'pass' ); - expect( req._options ).to.have.property( 'password' ); - expect( req._options.auth ).to.equal( true ); + describe( '.registerRoute()', function() { + + it( 'is a function', function() { + expect( site ).to.have.property( 'registerRoute' ); + expect( site.registerRoute ).to.be.a( 'function' ); + }); + }); - }); // auth + }); - describe( 'endpoint accessors', function() { + describe( 'instance has endpoint accessors', function() { - it( 'defines a media endpoint handler', function() { + it( 'for the media endpoint', function() { expect( site ).to.have.property( 'media' ); expect( site.media ).to.be.a( 'function' ); }); - it( 'defines a pages endpoint handler', function() { + it( 'for the pages endpoint', function() { expect( site ).to.have.property( 'pages' ); expect( site.pages ).to.be.a( 'function' ); }); - it( 'defines a posts endpoint handler', function() { + it( 'for the posts endpoint', function() { expect( site ).to.have.property( 'posts' ); expect( site.posts ).to.be.a( 'function' ); }); - it( 'defines a taxonomies endpoint handler', function() { + it( 'for the taxonomies endpoint', function() { expect( site ).to.have.property( 'taxonomies' ); expect( site.taxonomies ).to.be.a( 'function' ); }); - it( 'defines a categories endpoint handler', function() { + it( 'for the categories endpoint', function() { expect( site ).to.have.property( 'categories' ); expect( site.categories ).to.be.a( 'function' ); }); - it( 'defines a tags endpoint handler', function() { + it( 'for the tags endpoint', function() { expect( site ).to.have.property( 'tags' ); expect( site.tags ).to.be.a( 'function' ); }); - it( 'defines a types endpoint handler', function() { + it( 'for the types endpoint', function() { expect( site ).to.have.property( 'types' ); expect( site.types ).to.be.a( 'function' ); }); - it( 'defines a users endpoint handler', function() { + it( 'for the users endpoint', function() { expect( site ).to.have.property( 'users' ); expect( site.users ).to.be.a( 'function' ); }); }); - describe( '.discover()', function() { - - it( 'is a function', function() { - expect( WP ).to.have.property( 'discover' ); - expect( WP.discover ).to.be.a( 'function' ); - }); - - }); - }); diff --git a/wp.js b/wp.js index bbc66b10..32db635d 100644 --- a/wp.js +++ b/wp.js @@ -42,20 +42,28 @@ var autodiscovery = require( './lib/autodiscovery' ); // Pull in base module constructors var WPRequest = require( './lib/constructors/wp-request' ); +// Pull in default HTTP transport +var httpTransport = require( './lib/http-transport' ); + /** * The base constructor for the WP API service * * @class WP * @constructor * @uses WPRequest - * @param {Object} options An options hash to configure the instance - * @param {String} options.endpoint The URI for a WP-API endpoint - * @param {String} [options.username] A WP-API Basic Auth username - * @param {String} [options.password] A WP-API Basic Auth password - * @param {Object} [options.routes] A dictionary of API routes with which to - * bootstrap the WP instance: the instance will - * be initialized with default routes only - * if this property is omitted + * @param {Object} options An options hash to configure the instance + * @param {String} options.endpoint The URI for a WP-API endpoint + * @param {String} [options.username] A WP-API Basic Auth username + * @param {String} [options.password] A WP-API Basic Auth password + * @param {String} [options.nonce] A WP nonce for use with cookie authentication + * @param {Object} [options.routes] A dictionary of API routes with which to + * bootstrap the WP instance: the instance will + * be initialized with default routes only + * if this property is omitted + * @param {String} [options.transport] An optional dictionary of HTTP transport + * methods (.get, .post, .put, .delete, .head) + * to use instead of the defaults, e.g. to use + * a different HTTP library than superagent */ function WP( options ) { @@ -76,9 +84,48 @@ function WP( options ) { // Ensure trailing slash on endpoint URI this._options.endpoint = this._options.endpoint.replace( /\/?$/, '/' ); + // Create the HTTP transport object + this._options.transport = Object.assign( {}, httpTransport, options.transport ); + return this.bootstrap( options && options.routes ); } +/** + * Default HTTP transport methods object that can be extended to define custom + * HTTP transport behavior for a WP instance + * + * @example showing how a cache hit (keyed by URI) could short-circuit a get request + * + * var site = new WP({ + * endpoint: 'http://my-site.com/wp-json', + * transport: { + * get: function( wpquery, cb ) { + * var result = cache[ wpquery ]; + * // If a cache hit is found, return it via the same callback/promise + * // signature as the default transport method + * if ( result ) { + * if ( cb && typeof cb === 'function' ) { + * cb( null, result ); + * } + * return Promise.resolve( result ); + * } + + * // Delegate to default transport if no cached data was found + * return WP.transport.get( wpquery, cb ).then(function( result ) { + * cache[ wpquery ] = result; + * return result; + * }); + * } + * } + * }); + * + * @static + * @property transport + * @type {Object} + */ +WP.transport = Object.create( httpTransport ); +Object.freeze( WP.transport ); + /** * Convenience method for making a new WP instance *