From d17c6b2cc2e13ebfe129b016370eed3cba875d05 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 24 Jun 2016 21:54:02 +0200 Subject: [PATCH 1/4] Remove completely inexplicable parenthetical in documentation example --- wp.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wp.js b/wp.js index dd5c6abc..44d87a8b 100644 --- a/wp.js +++ b/wp.js @@ -96,7 +96,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()... * From 568401df4efe7d62ccf9a3e2137672f56fda158a Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 24 Jun 2016 23:33:22 +0200 Subject: [PATCH 2/4] Remove dead code paths in WPRequest --- lib/constructors/wp-request.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 ]; From d55f517d39b4d240fc4dc9b0332e264fc9758380 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Sun, 19 Jun 2016 12:49:23 -0400 Subject: [PATCH 3/4] Implement autodiscovery and revise default bootstrapping Contains unit tests & integration tests, with ~85% coverage Closes #74 The API this provides is as follows: ```js var apiPromise = WP.discover( 'http://my-site.com' ); apiPromise.then(function( site ) { // If default routes were detected, they are now available site.posts().then(function( posts ) { console.log( posts ); }); // etc // If custom routes were detected, they can be accessed via .namespace() site.namespace( 'myplugin/v1' ).authors() .then(function( authors ) { /* ... */ }); // Namespaces can be saved out to variables: var myplugin = site.namespace( 'myplugin/v1' ); myplugin.authors().id( 7 ).then(function( author ) { /* ... */ }); }); ``` TODO: Readme documentation --- lib/autodiscovery.js | 93 ++++++++++++ lib/endpoint-factories.js | 47 ++++--- lib/wp-register-route.js | 3 +- tests/integration/autodiscovery.js | 185 ++++++++++++++++++++++++ tests/unit/lib/autodiscovery.js | 96 +++++++++++++ tests/unit/wp.js | 219 ++++++++++++++++++++++++++++- wp.js | 196 +++++++++++++++++++++++--- 7 files changed, 802 insertions(+), 37 deletions(-) create mode 100644 lib/autodiscovery.js create mode 100644 tests/integration/autodiscovery.js create mode 100644 tests/unit/lib/autodiscovery.js 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/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/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/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 44d87a8b..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 + }); }; /** @@ -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; From 6f002159bcf3ca13b864cbd683d2f78c4bed1776 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Sat, 25 Jun 2016 00:14:12 +0200 Subject: [PATCH 4/4] Add missing dependency, upgrade package deps, and fix superagent error The new version of Chai apparently considers errors not to be objects --- .gitattributes | 2 +- package.json | 32 +++++++++++++++++--------------- tests/integration/posts.js | 20 +++++++++++++++----- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/.gitattributes b/.gitattributes index 587b786e..1b2bbcab 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -*.json -diff +./**/*.json -diff 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/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;