diff --git a/.gitattributes b/.gitattributes
index 587b786e..1b2bbcab 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1 @@
-*.json -diff
+./**/*.json -diff
diff --git a/lib/autodiscovery.js b/lib/autodiscovery.js
new file mode 100644
index 00000000..2fd1fddf
--- /dev/null
+++ b/lib/autodiscovery.js
@@ -0,0 +1,93 @@
+/**
+ * Utility methods used to query a site in order to discover its available
+ * API endpoints
+ *
+ * @module autodiscovery
+ */
+'use strict';
+
+/*jshint -W079 */// Suppress warning about redefiniton of `Promise`
+var Promise = require( 'bluebird' );
+var agent = require( 'superagent' );
+var parseLinkHeader = require( 'parse-link-header' );
+
+function resolveAsPromise( superagentReq ) {
+ return new Promise(function( resolve, reject ) {
+ superagentReq.end(function( err, res ) {
+ if ( err ) {
+ // If err.response is present, the request succeeded but we got an
+ // error from the server: surface & return that error
+ if ( err.response && err.response.error ) {
+ return reject( err.response.error );
+ }
+ // If err.response is not present, the request could not connect
+ return reject( err );
+ }
+ resolve( res );
+ });
+ });
+}
+
+/**
+ * Fetch the headers for a URL and inspect them to attempt to locate an API
+ * endpoint header. Return a promise that will be resolved with a string, or
+ * rejected if no such header can be located.
+ *
+ * @param {string} url An arbitrary URL within an API-enabled WordPress site
+ * @param {boolean} [useGET] Whether to use GET or HEAD to read the URL, to enable
+ * the method to upgrade to a full GET request if a HEAD
+ * request initially fails.
+ * @returns {Promise} A promise to the string containing the API endpoint URL
+ */
+function getAPIRootFromURL( url, useGET ) {
+
+ // If useGET is specified and truthy, .get the url; otherwise use .head
+ // because we only care about the HTTP headers, not the response body.
+ var request = useGET ? agent.get( url ) : agent.head( url );
+
+ return resolveAsPromise( request )
+ .catch(function( err ) {
+ // If this wasn't already a GET request, then on the hypothesis that an
+ // error arises from an unaccepted HEAD request, try again using GET
+ if ( ! useGET ) {
+ return getAPIRootFromURL( url, true );
+ }
+
+ // Otherwise re-throw the error
+ throw err;
+ });
+}
+
+function locateAPIRootHeader( response ) {
+ var rel = 'https://api.w.org/';
+
+ // Extract & parse the response link headers
+ var headers = parseLinkHeader( response.headers.link );
+ var apiHeader = headers && headers[ rel ];
+
+ if ( apiHeader && apiHeader.url ) {
+ return apiHeader.url;
+ }
+
+ throw new Error( 'No header link found with rel="https://api.w.org/"' );
+}
+
+/**
+ * Function to be called with the API url, once we have found one
+ *
+ * @param {String} linkUrl The href of the pointing to the API root
+ * @return {Promise} Promise that resolves once the API root has been inspected
+ */
+function getRootResponseJSON( apiRootURL ) {
+ return resolveAsPromise( agent.get( apiRootURL ).set( 'Accept', 'application/json' ) )
+ .then(function( response ) {
+ return response.body;
+ });
+}
+
+module.exports = {
+ resolveAsPromise: resolveAsPromise,
+ getAPIRootFromURL: getAPIRootFromURL,
+ locateAPIRootHeader: locateAPIRootHeader,
+ getRootResponseJSON: getRootResponseJSON
+};
diff --git a/lib/constructors/wp-request.js b/lib/constructors/wp-request.js
index 1da54df9..bd5517af 100644
--- a/lib/constructors/wp-request.js
+++ b/lib/constructors/wp-request.js
@@ -99,12 +99,11 @@ function ensureFunction( fn ) {
*
* @param {Object} request A superagent request object
* @param {Function} callback A callback function (optional)
- * @param {Function} transform A function to transform the result data (optional)
+ * @param {Function} transform A function to transform the result data
* @return {Promise} A promise to the superagent request
*/
function invokeAndPromisify( request, callback, transform ) {
callback = ensureFunction( callback );
- transform = transform || identity;
return new Promise(function( resolve, reject ) {
// Fire off the result
@@ -591,9 +590,7 @@ WPRequest.prototype._renderPath = function() {
.sort(function( a, b ) {
var intA = parseInt( a, 10 );
var intB = parseInt( b, 10 );
- if ( isNaN( intA ) && isNaN( intB ) ) {
- return intA - intB;
- }
+ return intA - intB;
})
.map(function( pathPartKey ) {
return pathParts[ pathPartKey ];
diff --git a/lib/endpoint-factories.js b/lib/endpoint-factories.js
index e26ebd5d..45c84709 100644
--- a/lib/endpoint-factories.js
+++ b/lib/endpoint-factories.js
@@ -14,33 +14,42 @@ var createEndpointRequest = require( './endpoint-request' ).create;
* provided namespace to define path value setters (and corresponding property
* validators) for all possible variants of each resource's API endpoints.
*
- * @param {string} namespace The namespace string for these routes
- * @param {object} routeDefinitions A dictionary of route definitions from buildRouteTree
+ * @param {string} namespace The namespace string for these routes
+ * @param {object} routesByNamespace A dictionary of namespace - route definition
+ * object pairs as generated from buildRouteTree,
+ * where each route definition object is a dictionary
+ * keyed by route definition strings
* @returns {object} A dictionary of endpoint request handler factories
*/
-function generateEndpointFactories( namespace, routeDefinitions ) {
+function generateEndpointFactories( routesByNamespace ) {
- // Create
- return Object.keys( routeDefinitions ).reduce(function( handlers, resource ) {
+ return Object.keys( routesByNamespace ).reduce(function( namespaces, namespace ) {
+ var routeDefinitions = routesByNamespace[ namespace ];
- var handlerSpec = createResourceHandlerSpec( routeDefinitions[ resource ], resource );
+ // Create
+ namespaces[ namespace ] = Object.keys( routeDefinitions ).reduce(function( handlers, resource ) {
- var EndpointRequest = createEndpointRequest( handlerSpec, resource, namespace );
+ var handlerSpec = createResourceHandlerSpec( routeDefinitions[ resource ], resource );
- // "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 );
- };
+ var EndpointRequest = createEndpointRequest( handlerSpec, resource, namespace );
- // Expose the constructor as a property on the factory function, so that
- // auto-generated endpoint request constructors may be further customized
- // when needed
- handlers[ resource ].Ctor = EndpointRequest;
+ // "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 handlers;
+ // Expose the constructor as a property on the factory function, so that
+ // auto-generated endpoint request constructors may be further customized
+ // when needed
+ handlers[ resource ].Ctor = EndpointRequest;
+
+ return handlers;
+ }, {} );
+
+ return namespaces;
}, {} );
}
diff --git a/lib/wp-register-route.js b/lib/wp-register-route.js
index e807b061..fd7014f3 100644
--- a/lib/wp-register-route.js
+++ b/lib/wp-register-route.js
@@ -57,7 +57,8 @@ function registerRoute( namespace, restBase, options ) {
// Go through the same steps used to bootstrap the client to parse the
// provided route out into a handler request method
var routeTree = buildRouteTree( routeObj );
- var endpointFactories = generateEndpointFactories( namespace, routeTree[ namespace ] );
+ // Parse the mock route object into endpoint factories
+ var endpointFactories = generateEndpointFactories( routeTree )[ namespace ];
var EndpointRequest = endpointFactories[ Object.keys( endpointFactories )[ 0 ] ].Ctor;
if ( options && typeof options.mixins === 'object' ) {
diff --git a/package.json b/package.json
index 98ddd6d5..92ac7793 100644
--- a/package.json
+++ b/package.json
@@ -41,30 +41,32 @@
"node": ">= 0.10.0"
},
"dependencies": {
- "bluebird": "^3.1.1",
+ "bluebird": "^3.4.1",
"li": "^1.0.1",
"lodash": "^2.4.2",
"node.extend": "^1.1.5",
- "qs": "^6.0.2",
- "route-parser": "^0.0.4",
- "superagent": "^1.7.0"
+ "parse-link-header": "^0.4.1",
+ "qs": "^6.2.0",
+ "route-parser": "0.0.4",
+ "superagent": "^1.8.3"
},
"devDependencies": {
- "chai": "^1.10.0",
- "chai-as-promised": "^4.3.0",
- "grunt": "^0.4.5",
- "grunt-cli": "^0.1.13",
- "grunt-contrib-yuidoc": "^0.5.2",
- "istanbul": "^0.3.22",
- "jscs": "^3.0.4",
+ "chai": "^3.5.0",
+ "chai-as-promised": "^5.3.0",
+ "grunt": "^1.0.1",
+ "grunt-cli": "^1.2.0",
+ "grunt-contrib-jshint": "^1.0.0",
+ "grunt-contrib-yuidoc": "^1.0.0",
+ "istanbul": "^0.4.4",
+ "jscs": "^3.0.5",
"jscs-stylish": "^0.3.1",
"jshint": "^2.9.2",
"jshint-stylish": "^2.2.0",
- "load-grunt-tasks": "^0.6.0",
+ "load-grunt-tasks": "^3.5.0",
"minimist": "^1.2.0",
- "mocha": "^1.21.5",
- "sandboxed-module": "^1.0.3",
- "sinon": "^1.17.2",
+ "mocha": "^2.5.3",
+ "sandboxed-module": "^2.0.3",
+ "sinon": "^1.17.4",
"sinon-chai": "^2.8.0"
}
}
diff --git a/tests/integration/autodiscovery.js b/tests/integration/autodiscovery.js
new file mode 100644
index 00000000..d9554744
--- /dev/null
+++ b/tests/integration/autodiscovery.js
@@ -0,0 +1,185 @@
+'use strict';
+var chai = require( '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' ) );
+chai.use( require( 'sinon-chai' ) );
+var expect = chai.expect;
+var sinon = require( 'sinon' );
+
+/*jshint -W079 */// Suppress warning about redefiniton of `Promise`
+var Promise = require( 'bluebird' );
+
+var WP = require( '../../' );
+var WPRequest = require( '../../lib/constructors/wp-request.js' );
+var autodiscovery = require( '../../lib/autodiscovery' );
+
+// Define some arrays to use ensuring the returned data is what we expect
+// it to be (e.g. an array of the titles from posts on the first page)
+var expectedResults = {
+ firstPostTitle: 'Markup: HTML Tags and Formatting'
+};
+
+// Inspecting the titles of the returned posts arrays is an easy way to
+// validate that the right page of results was returned
+function getTitles( posts ) {
+ return posts.map(function( post ) {
+ return post.title.rendered;
+ });
+}
+
+describe( 'integration: discover()', function() {
+ var apiPromise;
+ var sinonSandbox;
+
+ beforeEach(function() {
+ apiPromise = WP.discover( 'http://wpapi.loc' );
+ // Stub warn and error
+ sinonSandbox = sinon.sandbox.create();
+ sinonSandbox.stub( global.console, 'warn' );
+ sinonSandbox.stub( global.console, 'error' );
+ });
+
+ afterEach(function() {
+ // Restore sandbox
+ sinonSandbox.restore();
+ });
+
+ it( 'returns a promise', function() {
+ expect( apiPromise ).to.be.an.instanceOf( Promise );
+ });
+
+ it( 'eventually returns a configured WP instance', function() {
+ var prom = apiPromise.then(function( result ) {
+ expect( result ).to.be.an.instanceOf( WP );
+ expect( result.namespace( 'wp/v2' ) ).to.be.an( 'object' );
+ expect( result.posts ).to.be.a( 'function' );
+ expect( result.posts() ).to.be.an.instanceOf( WPRequest );
+ return SUCCESS;
+ });
+ return expect( prom ).to.eventually.equal( SUCCESS );
+ });
+
+ it( 'auto-binds to the detected endpoint on the provided site', function() {
+ var prom = apiPromise.then(function( site ) {
+ expect( site.posts()._renderURI() ).to.equal( 'http://wpapi.loc/wp-json/wp/v2/posts' );
+ return SUCCESS;
+ });
+ return expect( prom ).to.eventually.equal( SUCCESS );
+ });
+
+ it( 'can correctly instantiate requests against the detected and bound site', function() {
+ var prom = apiPromise.then(function( site ) {
+ return site.posts();
+ }).then(function( posts ) {
+ expect( getTitles( posts )[ 0 ] ).to.equal( expectedResults.firstPostTitle );
+ return SUCCESS;
+ });
+ return expect( prom ).to.eventually.equal( SUCCESS );
+ });
+
+ describe( 'rejection states', function() {
+
+ beforeEach(function() {
+ sinon.stub( autodiscovery, 'getAPIRootFromURL' );
+ sinon.stub( autodiscovery, 'locateAPIRootHeader' );
+ sinon.stub( autodiscovery, 'getRootResponseJSON' );
+ });
+
+ afterEach(function() {
+ autodiscovery.getAPIRootFromURL.restore();
+ autodiscovery.locateAPIRootHeader.restore();
+ autodiscovery.getRootResponseJSON.restore();
+ });
+
+ it( 'resolves even if no endpoint is found', function() {
+ autodiscovery.getAPIRootFromURL.returns( Promise.reject() );
+ var prom = WP.discover( 'http://we.made.it/to/mozarts/house' );
+ return expect( prom ).to.eventually.be.fulfilled;
+ });
+
+ it( 'resolves to null if no endpoint is found', function() {
+ autodiscovery.getAPIRootFromURL.returns( Promise.resolve() );
+ var prom = WP.discover( 'http://we.made.it/to/mozarts/house' )
+ .then(function( result ) {
+ expect( result ).to.equal( null );
+ return SUCCESS;
+ });
+ return expect( prom ).to.eventually.equal( SUCCESS );
+ });
+
+ it( 'logs a console error if no endpoint is found', function() {
+ autodiscovery.getAPIRootFromURL.returns( Promise.reject() );
+ var prom = WP.discover( 'http://we.made.it/to/mozarts/house' )
+ .then(function() {
+ expect( console.error ).to.have.been.calledWith( 'Autodiscovery failed' );
+ return SUCCESS;
+ });
+ return expect( prom ).to.eventually.equal( SUCCESS );
+ });
+
+ it( 'does not display any warnings if no endpoint is found', function() {
+ autodiscovery.getAPIRootFromURL.returns( Promise.reject() );
+ var prom = WP.discover( 'http://we.made.it/to/mozarts/house' )
+ .then(function() {
+ expect( console.warn ).not.to.have.been.called;
+ return SUCCESS;
+ });
+ return expect( prom ).to.eventually.equal( SUCCESS );
+ });
+
+ it( 'resolves to a WP instance if an endpoint is found but route autodiscovery fails', function() {
+ autodiscovery.getAPIRootFromURL.returns( Promise.resolve() );
+ autodiscovery.locateAPIRootHeader.returns( 'http://we.made.it/to/mozarts/house' );
+ autodiscovery.getRootResponseJSON.throws();
+ var prom = WP.discover()
+ .then(function( result ) {
+ expect( result ).to.be.an.instanceOf( WP );
+ return SUCCESS;
+ });
+ return expect( prom ).to.eventually.equal( SUCCESS );
+ });
+
+ it( 'binds returned instance to the provided endpoint even if route autodiscovery fails', function() {
+ autodiscovery.getAPIRootFromURL.returns( Promise.resolve() );
+ autodiscovery.locateAPIRootHeader.returns( 'http://we.made.it/to/mozarts/house' );
+ autodiscovery.getRootResponseJSON.throws();
+ var prom = WP.discover()
+ .then(function( result ) {
+ expect( result.root( '' )._renderURI() ).to.equal( 'http://we.made.it/to/mozarts/house/' );
+ return SUCCESS;
+ });
+ return expect( prom ).to.eventually.equal( SUCCESS );
+ });
+
+ it( 'logs a console error if an endpoint is found but route autodiscovery fails', function() {
+ autodiscovery.getAPIRootFromURL.returns( Promise.resolve() );
+ autodiscovery.locateAPIRootHeader.returns( 'http://we.made.it/to/mozarts/house' );
+ autodiscovery.getRootResponseJSON.throws();
+ var prom = WP.discover()
+ .then(function() {
+ expect( console.error ).to.have.been.calledWith( 'Autodiscovery failed' );
+ return SUCCESS;
+ });
+ return expect( prom ).to.eventually.equal( SUCCESS );
+ });
+
+ it( 'displays a warning if an endpoint is detected but route autodiscovery fails', function() {
+ autodiscovery.getAPIRootFromURL.returns( Promise.resolve() );
+ autodiscovery.locateAPIRootHeader.returns( 'http://we.made.it/to/mozarts/house' );
+ autodiscovery.getRootResponseJSON.throws();
+ var prom = WP.discover()
+ .then(function() {
+ expect( console.warn ).to.have.been.calledWith( 'Endpoint detected, proceeding despite error...' );
+ expect( console.warn ).to.have.been.calledWith( 'Binding to http://we.made.it/to/mozarts/house and assuming default routes' );
+ return SUCCESS;
+ });
+ return expect( prom ).to.eventually.equal( SUCCESS );
+ });
+
+ });
+
+});
diff --git a/tests/integration/posts.js b/tests/integration/posts.js
index 6fdef49d..bc2bceb9 100644
--- a/tests/integration/posts.js
+++ b/tests/integration/posts.js
@@ -7,6 +7,7 @@ var SUCCESS = 'success';
// actually run.
chai.use( require( 'chai-as-promised' ) );
var expect = chai.expect;
+var sinon = require( 'sinon' );
/*jshint -W079 */// Suppress warning about redefiniton of `Promise`
var Promise = require( 'bluebird' );
@@ -69,13 +70,22 @@ function getTitles( posts ) {
describe( 'integration: posts()', function() {
var wp;
+ var sinonSandbox;
beforeEach(function() {
+ // Stub warn to suppress notice about overwriting deprecated .post method
+ sinonSandbox = sinon.sandbox.create();
+ sinonSandbox.stub( global.console, 'warn' );
wp = new WP({
endpoint: 'http://wpapi.loc/wp-json'
});
});
+ afterEach(function() {
+ // Restore sandbox
+ sinonSandbox.restore();
+ });
+
it( 'can be used to retrieve a list of recent posts', function() {
var prom = wp.posts().get().then(function( posts ) {
expect( posts ).to.be.an( 'array' );
@@ -278,7 +288,7 @@ describe( 'integration: posts()', function() {
id = posts[ 0 ].id;
return wp.posts().id( id ).delete();
}).catch(function( err ) {
- expect( err ).to.be.an( 'object' );
+ expect( err ).to.be.an.instanceOf( Error );
expect( err ).to.have.property( 'status' );
expect( err.status ).to.equal( 401 );
// Ensure that the post was NOT deleted by querying for it again
@@ -296,7 +306,7 @@ describe( 'integration: posts()', function() {
title: 'New Post 2501',
content: 'Some Content'
}).catch(function( err ) {
- expect( err ).to.be.an( 'object' );
+ expect( err ).to.be.an.instanceOf( Error );
expect( err ).to.have.property( 'status' );
expect( err.status ).to.equal( 401 );
return SUCCESS;
@@ -313,7 +323,7 @@ describe( 'integration: posts()', function() {
content: 'Some Content'
});
}).catch(function( err ) {
- expect( err ).to.be.an( 'object' );
+ expect( err ).to.be.an.instanceOf( Error );
expect( err ).to.have.property( 'status' );
expect( err.status ).to.equal( 401 );
return SUCCESS;
@@ -381,7 +391,7 @@ describe( 'integration: posts()', function() {
// the unauthenticated user does not have permissions to see it
return wp.posts().id( id );
}).catch(function( error ) {
- expect( error ).to.be.an( 'object' );
+ expect( error ).to.be.an.instanceOf( Error );
expect( error ).to.have.property( 'status' );
expect( error.status ).to.equal( 403 );
// Re-authenticate & permanently delete this post
@@ -396,7 +406,7 @@ describe( 'integration: posts()', function() {
// just trashed but now deleted permanently
return wp.posts().auth( credentials ).id( id );
}).catch(function( error ) {
- expect( error ).to.be.an( 'object' );
+ expect( error ).to.be.an.instanceOf( Error );
expect( error ).to.have.property( 'status' );
expect( error.status ).to.equal( 404 );
return SUCCESS;
diff --git a/tests/unit/lib/autodiscovery.js b/tests/unit/lib/autodiscovery.js
new file mode 100644
index 00000000..e8b67d73
--- /dev/null
+++ b/tests/unit/lib/autodiscovery.js
@@ -0,0 +1,96 @@
+'use strict';
+var chai = require( 'chai' );
+var expect = chai.expect;
+chai.use( require( 'chai-as-promised' ) );
+
+/*jshint -W079 */// Suppress warning about redefiniton of `Promise`
+var Promise = require( 'bluebird' );
+
+var autodiscovery = require( '../../../lib/autodiscovery' );
+
+describe( 'autodiscovery methods', function() {
+
+ describe( '.resolveAsPromise()', function() {
+ var resolveAsPromise;
+ var err;
+ var res;
+ var mockAgent;
+
+ beforeEach(function() {
+ resolveAsPromise = autodiscovery.resolveAsPromise;
+
+ // Default return values for the mock agent
+ err = null;
+ res = 'Response';
+
+ // Mock superagent
+ mockAgent = {
+ end: function( cb ) {
+ cb( err, res );
+ }
+ };
+ });
+
+ it( 'is a function', function() {
+ expect( resolveAsPromise ).to.be.a( 'function' );
+ });
+
+ it( 'returns a promise', function() {
+ var prom = resolveAsPromise( mockAgent );
+ expect( prom ).to.be.an.instanceOf( Promise );
+ });
+
+ it( 'resolves the promise with the response from the agent end method', function() {
+ var prom = resolveAsPromise( mockAgent );
+ return expect( prom ).to.eventually.equal( 'Response' );
+ });
+
+ it( 'rejects if the agent end method is called with an error', function() {
+ err = 'Some error';
+ var prom = resolveAsPromise( mockAgent );
+ return expect( prom ).to.eventually.be.rejectedWith( 'Some error' );
+ });
+
+ it( 'rejects with the error\'s response.error property when available', function() {
+ err = {
+ response: {
+ error: '404 yo'
+ }
+ };
+ var prom = resolveAsPromise( mockAgent );
+ return expect( prom ).to.eventually.be.rejectedWith( '404 yo' );
+ });
+
+ });
+
+ describe( '.locateAPIRootHeader()', function() {
+ var locateAPIRootHeader;
+
+ beforeEach(function() {
+ locateAPIRootHeader = autodiscovery.locateAPIRootHeader;
+ });
+
+ it( 'is a function', function() {
+ expect( locateAPIRootHeader ).to.be.a( 'function' );
+ });
+
+ it( 'throws an error if no link header is found', function() {
+ expect(function() {
+ locateAPIRootHeader({
+ headers: {}
+ });
+ }).to.throw( 'No header link found with rel="https://api.w.org/"' );
+ });
+
+ it( 'parsed and returns the header with the rel for the WP api endpoint', function() {
+ var result = locateAPIRootHeader({
+ headers: {
+ link: '; rel="https://api.w.org/"'
+ }
+ });
+ expect( result ).to.equal( 'http://wpapi.loc/wp-json/' );
+ });
+
+ });
+
+});
diff --git a/tests/unit/wp.js b/tests/unit/wp.js
index d14028a7..2cbf0f4a 100644
--- a/tests/unit/wp.js
+++ b/tests/unit/wp.js
@@ -47,12 +47,161 @@ describe( 'wp', function() {
describe( '.site()', function() {
- it( 'Creates and returns a new WP instance', function() {
+ it( 'is a function', function() {
+ expect( WP ).to.have.property( 'site' );
+ expect( WP.site ).to.be.a( 'function' );
+ });
+
+ it( 'creates and returns a new WP instance', function() {
var site = WP.site( 'endpoint/url' );
expect( site instanceof WP ).to.be.true;
expect( site._options.endpoint ).to.equal( 'endpoint/url/' );
});
+ it( 'can take a routes configuration object to bootstrap the returned instance', function() {
+ var site = WP.site( 'endpoint/url', {
+ '/wp/v2/posts': {
+ namespace: 'wp/v2',
+ methods: [ 'GET' ],
+ endpoints: [ {
+ methods: [ 'GET' ],
+ args: {
+ filter: { required: false }
+ }
+ } ]
+ }
+ });
+ expect( site instanceof WP ).to.be.true;
+ expect( site.posts ).to.be.a( 'function' );
+ expect( site ).not.to.have.property( 'comments' );
+ expect( site.posts() ).not.to.have.property( 'id' );
+ expect( site.posts().filter ).to.be.a( 'function' );
+ expect( site.posts()._renderURI() ).to.equal( 'endpoint/url/wp/v2/posts' );
+ });
+
+ });
+
+ describe( '.namespace()', function() {
+
+ it( 'is a function', function() {
+ expect( site ).to.have.property( 'namespace' );
+ expect( site.namespace ).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' );
+ });
+
+ 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' );
+ });
+
+ it( 'throws an error when provided no namespace', function() {
+ expect(function() {
+ site.namespace();
+ }).to.throw();
+ });
+
+ it( 'throws an error when provided an unregistered namespace', function() {
+ expect(function() {
+ site.namespace( 'foo/baz' );
+ }).to.throw();
+ });
+
+ });
+
+ 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( 'is a function', function() {
+ expect( site ).to.have.property( 'bootstrap' );
+ expect( site.bootstrap ).to.be.a( 'function' );
+ });
+
+ 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' );
+ });
+
+ 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' )._renderURI() ).to.equal( 'endpoint/url/wp/v2/customendpoint/foobar' );
+ });
+
+ 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( '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 );
+ });
+
});
describe( '.url()', function() {
@@ -128,6 +277,65 @@ describe( 'wp', function() {
});
+ describe( 'auth', function() {
+
+ beforeEach(function() {
+ site = new WP({ endpoint: 'http://my.site.com/wp-json' });
+ });
+
+ 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;
+ });
+
+ 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( '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 );
+ });
+
+ }); // auth
+
describe( 'endpoint accessors', function() {
it( 'defines a media endpoint handler', function() {
@@ -172,4 +380,13 @@ describe( 'wp', 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 dd5c6abc..76a5c6af 100644
--- a/wp.js
+++ b/wp.js
@@ -19,15 +19,25 @@
var extend = require( 'node.extend' );
// All valid routes in API v2 beta 11
-var routes = require( './lib/data/endpoint-response.json' ).routes;
+var defaultRoutes = require( './lib/data/endpoint-response.json' ).routes;
var buildRouteTree = require( './lib/route-tree' ).build;
var generateEndpointFactories = require( './lib/endpoint-factories' ).generate;
+// The default endpoint factories will be lazy-loaded by parsing the default
+// route tree data if a default-mode WP instance is created (i.e. one that
+// is to be bootstrapped with the handlers for all of the built-in routes)
+var defaultEndpointFactories;
+
var defaults = {
username: '',
password: ''
};
+var apiDefaultNamespace = 'wp/v2';
+
+// Pull in autodiscovery methods
+var autodiscovery = require( './lib/autodiscovery' );
+
// Pull in base module constructors
var WPRequest = require( './lib/constructors/wp-request' );
@@ -36,13 +46,15 @@ var WPRequest = require( './lib/constructors/wp-request' );
*
* @class WP
* @constructor
- * @uses PostsRequest
- * @uses TaxonomiesRequest
- * @uses UsersRequest
+ * @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
*/
function WP( options ) {
@@ -51,6 +63,9 @@ function WP( options ) {
return new WP( options );
}
+ // Dictionary to be filled by handlers for default namespaces
+ this._ns = {};
+
this._options = extend( {}, defaults, options );
if ( ! this._options.endpoint ) {
@@ -60,18 +75,9 @@ function WP( options ) {
// Ensure trailing slash on endpoint URI
this._options.endpoint = this._options.endpoint.replace( /\/?$/, '/' );
- return this;
+ return this.bootstrap( options && options.routes );
}
-// Auto-generate default endpoint factories
-var routesByNamespace = buildRouteTree( routes );
-var endpointFactories = generateEndpointFactories( 'wp/v2', routesByNamespace[ 'wp/v2' ] );
-
-// Apply all auto-generated endpoint factories to the WP object prototype
-Object.keys( endpointFactories ).forEach(function( methodName ) {
- WP.prototype[ methodName ] = endpointFactories[ methodName ];
-});
-
/**
* Convenience method for making a new WP instance
*
@@ -81,13 +87,36 @@ Object.keys( endpointFactories ).forEach(function( methodName ) {
* var wp = new WP({ endpoint: 'http://my.blog.url/wp-json' });
* var wp = WP.site( 'http://my.blog.url/wp-json' );
*
+ * `WP.site` can take an optional API root response JSON object to use when
+ * bootstrapping the client's endpoint handler methods: if no second parameter
+ * is provided, the client instance is assumed to be using the default API
+ * with no additional plugins and is initialized with handlers for only those
+ * default API routes.
+ *
+ * @example
+ * These are equivalent:
+ *
+ * // {...} means the JSON output of http://my.blog.url/wp-json
+ * var wp = new WP({
+ * endpoint: 'http://my.blog.url/wp-json',
+ * json: {...}
+ * });
+ * var wp = WP.site( 'http://my.blog.url/wp-json', {...} );
+ *
* @method site
* @static
* @param {String} endpoint The URI for a WP-API endpoint
+ * @param {Object} routes The "routes" object from the JSON object returned
+ * from the root API endpoint of a WP site, which should
+ * be a dictionary of route definition objects keyed by
+ * the route's regex pattern
* @return {WP} A new WP instance, bound to the provided endpoint
*/
-WP.site = function( endpoint ) {
- return new WP({ endpoint: endpoint });
+WP.site = function( endpoint, routes ) {
+ return new WP({
+ endpoint: endpoint,
+ routes: routes
+ });
};
/**
@@ -96,7 +125,7 @@ WP.site = function( endpoint ) {
* the endpoint specified during WP object instantiation.
*
* @example
- * Generate a request to the explicit URL "http://your.website.com/wp-json/some/custom/path" (yeah, we wish ;)
+ * Generate a request to the explicit URL "http://your.website.com/wp-json/some/custom/path"
*
* wp.url( 'http://your.website.com/wp-json/some/custom/path' ).get()...
*
@@ -131,7 +160,142 @@ WP.prototype.root = function( relativePath ) {
return request;
};
+WP.prototype.auth = WPRequest.prototype.auth;
+
// Apply the registerRoute method to the prototype
WP.prototype.registerRoute = require( './lib/wp-register-route' );
+/**
+ * Deduce request methods from a provided API root JSON response object's
+ * routes dictionary, and assign those methods to the current instance. If
+ * no routes dictionary is provided then the instance will be bootstrapped
+ * with route handlers for the default API endpoints only.
+ *
+ * This method is called automatically during WP instance creation.
+ *
+ * @method bootstrap
+ * @chainable
+ * @param {Object} routes The "routes" object from the JSON object returned
+ * from the root API endpoint of a WP site, which should
+ * be a dictionary of route definition objects keyed by
+ * the route's regex pattern
+ * @return {WP} The bootstrapped WP client instance (for chaining or assignment)
+ */
+WP.prototype.bootstrap = function( routes ) {
+ var routesByNamespace;
+ var endpointFactoriesByNamespace;
+
+ if ( ! routes ) {
+ // Auto-generate default endpoint factories if they are not already available
+ if ( ! defaultEndpointFactories ) {
+ routesByNamespace = buildRouteTree( defaultRoutes );
+ defaultEndpointFactories = generateEndpointFactories( routesByNamespace );
+ }
+ endpointFactoriesByNamespace = defaultEndpointFactories;
+ } else {
+ routesByNamespace = buildRouteTree( routes );
+ endpointFactoriesByNamespace = generateEndpointFactories( routesByNamespace );
+ }
+
+ // For each namespace for which routes were identified, store the generated
+ // route handlers on the WP instance's private _ns dictionary. These namespaced
+ // handler methods can be accessed by calling `.namespace( str )` on the
+ // client instance and passing a registered namespace string.
+ // Handlers for default (wp/v2) routes will also be assigned to the WP client
+ // instance object itself, for brevity.
+ return Object.keys( endpointFactoriesByNamespace ).reduce(function( wpInstance, namespace ) {
+ var endpointFactories = endpointFactoriesByNamespace[ namespace ];
+
+ // Set (or augment) the route handler factories for this namespace.
+ wpInstance._ns[ namespace ] = Object.keys( endpointFactories ).reduce(function( nsHandlers, methodName ) {
+ nsHandlers[ methodName ] = endpointFactories[ methodName ];
+ return nsHandlers;
+ }, wpInstance._ns[ namespace ] || {
+ // Create all namespace dictionaries with a direct reference to the main WP
+ // instance's _options property so that things like auth propagate properly
+ _options: wpInstance._options
+ } );
+
+ // For the default namespace, e.g. "wp/v2" at the time this comment was
+ // written, ensure all methods are assigned to the root client object itself
+ // in addition to the private _ns dictionary: this is done so that these
+ // methods can be called with e.g. `wp.posts()` and not the more verbose
+ // `wp.namespace( 'wp/v2' ).posts()`.
+ if ( namespace === apiDefaultNamespace ) {
+ Object.keys( wpInstance._ns[ namespace ] ).forEach(function( methodName ) {
+ wpInstance[ methodName ] = wpInstance._ns[ namespace ][ methodName ];
+ });
+ }
+
+ return wpInstance;
+ }, this );
+};
+
+/**
+ * Access API endpoint handlers from a particular API namespace object
+ *
+ * @example
+ *
+ * wp.namespace( 'myplugin/v1' ).author()...
+ *
+ * // Default WP endpoint handlers are assigned to the wp instance itself.
+ * // These are equivalent:
+ * wp.namespace( 'wp/v2' ).posts()...
+ * wp.posts()...
+ *
+ * @param {string} namespace A namespace string
+ * @returns {Object} An object of route endpoint handler methods for the
+ * routes within the specified namespace
+ */
+WP.prototype.namespace = function( namespace ) {
+ if ( ! this._ns[ namespace ] ) {
+ throw new Error( 'Error: namespace ' + namespace + ' is not recognized' );
+ }
+ return this._ns[ namespace ];
+};
+
+/**
+ * Take an arbitrary WordPress site, deduce the WP REST API root endpoint, query
+ * that endpoint, and parse the response JSON. Use the returned JSON response
+ * to instantiate a WP instance bound to the provided site.
+ *
+ * @method discover
+ * @static
+ * @param {string} url A URL within a WP endpoint
+ * @return {Promise} A promise that resolves to a configured WP instance bound
+ * to the deduced endpoint, or rejected if an endpoint is not found or the
+ * library is unable to parse the provided endpoint.
+ */
+WP.discover = function( url ) {
+ // local placeholder for API root URL
+ var endpoint;
+
+ return autodiscovery.getAPIRootFromURL( url )
+ .then( autodiscovery.locateAPIRootHeader )
+ .then(function( apiRootURL ) {
+ // Set the function-scope variable that will be used to instantiate
+ // the bound WP instance, then pass the URL on
+ endpoint = apiRootURL;
+ return apiRootURL;
+ })
+ .then( autodiscovery.getRootResponseJSON )
+ .then(function( apiRootJSON ) {
+ // Instantiate & bootstrap with the discovered methods
+ return new WP({
+ endpoint: endpoint,
+ routes: apiRootJSON.routes
+ });
+ })
+ .catch(function( err ) {
+ console.error( 'Autodiscovery failed' );
+ console.error( err );
+ if ( endpoint ) {
+ console.warn( 'Endpoint detected, proceeding despite error...' );
+ console.warn( 'Binding to ' + endpoint + ' and assuming default routes' );
+ return new WP.site( endpoint );
+ }
+ return null;
+ });
+};
+
module.exports = WP;