diff --git a/README.md b/README.md index f7748a62..63dd78fa 100644 --- a/README.md +++ b/README.md @@ -3,20 +3,28 @@ A WordPress REST API client for JavaScript This is a client for the [WordPress REST API](http://wp-api.org/). It is **under active development**, and should be considered beta software. More features are in progress, and **[issues](https://github.com/kadamwhite/wordpress-rest-api/issues)** are welcome if you find something that doesn't work! -**`wordpress-rest-api` is designed to work with [WP-API](https://github.com/WP-API/WP-API) v2 beta 11 or higher.** If you use a prior version of the beta, some commands will not work. +**`wordpress-rest-api` is designed to work with [WP-API](https://github.com/WP-API/WP-API) v2 beta 11 or higher.** If you use a prior version of the beta, some commands will not work. The latest beta is always recommended! [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/kadamwhite/wordpress-rest-api?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Build Status](https://api.travis-ci.org/kadamwhite/wordpress-rest-api.png?branch=master)](https://travis-ci.org/kadamwhite/wordpress-rest-api) **Index**: -[Purpose](#purpose) • -[Installation](#installation) • -[Using The Client](#using-the-client) • -[Authentication](#authentication) • -[API Documentation](#api-documentation) • -[Issues](#issues) • -[Contributing](#contributing) + +- [Purpose](#purpose) +- [Installation](#installation) +- [Using The Client](#using-the-client) + - [Creating Posts](#creating-posts) + - [Updating Posts](#updating-posts) + - [Requesting Different Resources](#requesting-different-resources) + - [Filtering Collections](#filtering-collections) + - [Custom Routes](#custom-routes) + - [Embedding Data](#embedding-data) + - [Paginated Collections](#working-with-paged-response-data) + - [Authentication](#authentication) +- [API Documentation](#api-documentation) +- [Issues](#issues) +- [Contributing](#contributing) ## Purpose @@ -223,9 +231,47 @@ The following methods are shortcuts for filtering the requested collection down * `.month( month )`: find items published in the specified month, designated by the month index (1–12) or name (*e.g.* "February") * `.day( day )`: find items published on the specified day -### Custom Post Types +### Custom Routes + +Support for Custom Post Types is provided via the `.registerRoute` method. This method returns a handler function which can be assigned to your site instance as a method, and takes the [same namespace and route string arguments as `rest_register_route`](http://v2.wp-api.org/extending/adding/#bare-basics): + +```js +var site = new WP({ endpoint: 'http://www.yoursite.com/wp-json' }); +site.myCustomResource = site.registerRoute( 'myplugin/v1', '/author/(?P)' ); +site.myCustomResource().id( 17 ); // => myplugin/v1/author/17 +``` + +The string `(?P)` indicates that a level of the route for this resource is a dynamic property named ID. By default, properties identified in this fashion will not have any inherent validation. This is designed to give developers the flexibility to pass in anything, with the caveat that only valid IDs will be accepted on the WordPress end. + +You might notice that in the example from the official WP-API documentation, a pattern is specified with a different format: this is a [regular expression](http://www.regular-expressions.info/tutorial.html) designed to validate the values that may be used for this capture group. +```js +var site = new WP({ endpoint: 'http://www.yoursite.com/wp-json' }); +site.myCustomResource = site.registerRoute( 'myplugin/v1', '/author/(?P\\d+)' ); +site.myCustomResource().id( 7 ); // => myplugin/v1/author/7 +site.myCustomResource().id( 'foo' ); // => Error: Invalid path component: foo does not match (?P\d+) +``` +Adding the regular expression pattern (as a string) enabled validation for this component. In this case, the `\\d+` will cause only _numeric_ values to be accepted. + +**NOTE THE DOUBLE-SLASHES** in the route definition here, however: `'/author/(?P\\d+)'` This is a JavaScript string, where `\` _must_ be written as `\\` to be parsed properly. A single backslash will break the route's validation. + +Each named group in the route will be converted into a named setter method on the route handler, as in `.id()` in the example above: that name is taken from the `` in the route string. + +The route string `'pages/(?P[\d]+)/revisions/(?P[\d]+)'` would create the setters `.parentPage()` and `id()`, permitting any permutation of the provided URL to be created. + +To permit custom parameter support methods on custom endpoints, a configuration object may be passed to the `registerRoute` method with a `mixins` property defining any functions to add: + +```js +site.handler = site.registerRoute( 'myplugin/v1', 'collection/(?P)', { + mixins: { + myParam: function( val ) { + return this.param( 'my_param', val ); + } + } +}); +``` +This permits a developer to extend an endpoint with arbitrary parameters in the same manner as is done for the automatically-generated built-in route handlers. -Support for Custom Post Types has been removed temporarily, but will be reinstated soon once the client supports the new custom post handling changes introduced in the new v2 API betas. +Auto-discovery of all available routes will be supported in the near future, as will re-utilizing existing mixins (like `.search()`) on custom routes. ## Embedding data diff --git a/lib/endpoint-factories.js b/lib/endpoint-factories.js index 19cc8fd8..e26ebd5d 100644 --- a/lib/endpoint-factories.js +++ b/lib/endpoint-factories.js @@ -35,6 +35,11 @@ function generateEndpointFactories( namespace, routeDefinitions ) { return new EndpointRequest( options ); }; + // 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; }, {} ); } diff --git a/lib/endpoint-request.js b/lib/endpoint-request.js index 2937b5e2..250b12b5 100644 --- a/lib/endpoint-request.js +++ b/lib/endpoint-request.js @@ -30,22 +30,29 @@ function createEndpointRequest( handlerSpec, resource, namespace ) { // Mix in all available shortcut methods for GET request query parameters that // are valid within this endpoint tree - Object.keys( handlerSpec._getArgs ).forEach(function( supportedQueryParam ) { - var mixinsForParam = mixins[ supportedQueryParam ]; - - // Only proceed if there is a mixin available AND the specified mixins will - // not overwrite any previously-set prototype method - if ( mixinsForParam ) { - Object.keys( mixinsForParam ).forEach(function( methodName ) { - if ( ! EndpointRequest.prototype[ methodName ] ) { - EndpointRequest.prototype[ methodName ] = mixinsForParam[ methodName ]; - } - }); - } - }); + if ( typeof handlerSpec._getArgs === 'object' ) { + Object.keys( handlerSpec._getArgs ).forEach(function( supportedQueryParam ) { + var mixinsForParam = mixins[ supportedQueryParam ]; + + // Only proceed if there is a mixin available AND the specified mixins will + // not overwrite any previously-set prototype method + if ( mixinsForParam ) { + Object.keys( mixinsForParam ).forEach(function( methodName ) { + if ( ! EndpointRequest.prototype[ methodName ] ) { + EndpointRequest.prototype[ methodName ] = mixinsForParam[ methodName ]; + } + }); + } + }); + } Object.keys( handlerSpec._setters ).forEach(function( setterFnName ) { - EndpointRequest.prototype[ setterFnName ] = handlerSpec._setters[ setterFnName ]; + if ( EndpointRequest.prototype[ setterFnName ] ) { + console.warn( 'Warning: method .' + setterFnName + '() is already defined!' ); + console.warn( 'Cannot overwrite .' + resource + '().' + setterFnName + '() method' ); + } else { + EndpointRequest.prototype[ setterFnName ] = handlerSpec._setters[ setterFnName ]; + } }); return EndpointRequest; diff --git a/lib/path-part-setter.js b/lib/path-part-setter.js index 48f4bf1a..5385c19e 100644 --- a/lib/path-part-setter.js +++ b/lib/path-part-setter.js @@ -15,7 +15,7 @@ function createPathPartSetter( node ) { // Local references to `node` properties used by returned functions var nodeLevel = node.level; var nodeName = node.names[ 0 ]; - var supportedMethods = node.methods; + var supportedMethods = node.methods || []; var dynamicChildren = node.children ? Object.keys( node.children ) .map(function( key ) { return node.children[ key ]; @@ -42,7 +42,9 @@ function createPathPartSetter( node ) { return function( val ) { /* jshint validthis:true */ this.setPathPart( nodeLevel, val ); - this._supportedMethods = supportedMethods; + if ( supportedMethods.length ) { + this._supportedMethods = supportedMethods; + } return this; }; } else { diff --git a/lib/resource-handler-spec.js b/lib/resource-handler-spec.js index 03e8dd5f..f66e4524 100644 --- a/lib/resource-handler-spec.js +++ b/lib/resource-handler-spec.js @@ -79,7 +79,7 @@ function extractSetterFromNode( handler, node ) { function createNodeHandlerSpec( routeDefinition, resource ) { var handler = { - // A "path" is an ordered set of + // A "path" is an ordered (by key) set of values composed into the final URL _path: { '0': resource }, @@ -109,8 +109,5 @@ function createNodeHandlerSpec( routeDefinition, resource ) { } module.exports = { - create: createNodeHandlerSpec, - _extractSetterFromNode: extractSetterFromNode, - _assignSetterFnForNode: assignSetterFnForNode, - _addLevelOption: addLevelOption + create: createNodeHandlerSpec }; diff --git a/lib/route-tree.js b/lib/route-tree.js index 3e878067..657ddeba 100644 --- a/lib/route-tree.js +++ b/lib/route-tree.js @@ -40,7 +40,12 @@ function reduceRouteComponents( routeObj, topLevel, parentLevel, component, idx, // pattern as the unique identifier: this is done because the same group // could be assigned different names in different endpoint handlers, e.g. // "id" for posts/:id vs "parent_id" for posts/:parent_id/revisions. - var levelKey = namedGroup ? groupPattern : component; + // + // There is an edge case where groupPattern will be "" if we are registering + // a custom route via `.registerRoute` that does not include parameter + // validation. In this case we assume the groupName is sufficiently unique, + // and fall back to `|| groupName` for the levelKey string. + var levelKey = namedGroup ? groupPattern : component || groupName; // Level name, on the other hand, would take its value from the group's name var levelName = namedGroup ? groupName : component; @@ -66,7 +71,11 @@ function reduceRouteComponents( routeObj, topLevel, parentLevel, component, idx, // on the request URL is of the proper type for the location in which it // is specified. If a group pattern was found, the validator checks whether // the input string exactly matches the group pattern. - var groupPatternRE = new RegExp( groupPattern ? '^' + groupPattern + '$' : component ); + var groupPatternRE = groupPattern === '' ? + // If groupPattern is an empty string, accept any input without validation + /.*/ : + // Otherwise, validate against the group pattern or the component string + new RegExp( groupPattern ? '^' + groupPattern + '$' : component, 'i' ); // Only one validate function is maintained for each node, because each node // is defined either by a string literal or by a specific regular expression. @@ -85,7 +94,7 @@ function reduceRouteComponents( routeObj, topLevel, parentLevel, component, idx, }) : []; // Ensure HEAD is included whenever GET is supported: the API automatically // adds support for HEAD if you have GET - if ( currentLevel.methods.indexOf( 'get' ) > -1 ) { + if ( currentLevel.methods.indexOf( 'get' ) > -1 && currentLevel.methods.indexOf( 'head' ) === -1 ) { currentLevel.methods.push( 'head' ); } diff --git a/lib/util/named-group-regexp.js b/lib/util/named-group-regexp.js index 39b9e6a6..a3ae04c9 100644 --- a/lib/util/named-group-regexp.js +++ b/lib/util/named-group-regexp.js @@ -19,7 +19,7 @@ module.exports = new RegExp([ '[>\']', // Get everything up to the end of the capture group: this is the RegExp used // when matching URLs to this route, which we can use for validation purposes. - '([^\\)]+)', + '([^\\)]*)', // Capture group end '\\)' ].join( '' ) ); diff --git a/lib/wp-register-route.js b/lib/wp-register-route.js new file mode 100644 index 00000000..e807b061 --- /dev/null +++ b/lib/wp-register-route.js @@ -0,0 +1,87 @@ +'use strict'; + +var extend = require( 'node.extend' ); + +var buildRouteTree = require( './route-tree' ).build; +var generateEndpointFactories = require( './endpoint-factories' ).generate; + +/** + * Create and return a handler for an arbitrary WP REST API endpoint. + * + * The first two parameters mirror `register_rest_route` in the REST API + * codebase: + * + * @class wp + * @method registerRoute + * @param {string} namespace A namespace string, e.g. 'myplugin/v1' + * @param {string} restBase A REST route string, e.g. '/author/(?P\d+)' + * @param {object} [options] An (optional) options object + * @param {object} [options.mixins] A hash of functions to apply as mixins + * @param {string[]} [options.methods] An array of methods to whitelist (on the leaf node only) + * @returns {Function} An endpoint handler factory function for the + * specified route + */ +function registerRoute( namespace, restBase, options ) { + // Support all methods until requested to do otherwise + var supportedMethods = [ 'head', 'get', 'patch', 'put', 'post', 'delete' ]; + + if ( options && Array.isArray( options.methods ) ) { + // Permit supported methods to be specified as an array + supportedMethods = options.methods.map(function( method ) { + return method.trim().toLowerCase(); + }); + } else if ( options && typeof options.methods === 'string' ) { + // Permit a supported method to be specified as a string + supportedMethods = [ options.methods.trim().toLowerCase() ]; + } + + // Ensure that if GET is supported, then HEAD is as well, and vice-versa + if ( supportedMethods.indexOf( 'get' ) !== -1 && supportedMethods.indexOf( 'head' ) === -1 ) { + supportedMethods.push( 'head' ); + } else if ( supportedMethods.indexOf( 'head' ) !== -1 && supportedMethods.indexOf( 'get' ) === -1 ) { + supportedMethods.push( 'get' ); + } + + var fullRoute = namespace + // Route should always have preceding slash + .replace( /^[\s/]*/, '/' ) + // Route should always be joined to namespace with a single slash + .replace( /[\s/]*$/, '/' ) + restBase.replace( /^[\s/]*/, '' ); + + var routeObj = {}; + routeObj[ fullRoute ] = { + namespace: namespace, + methods: supportedMethods + }; + + // 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 ] ); + var EndpointRequest = endpointFactories[ Object.keys( endpointFactories )[ 0 ] ].Ctor; + + if ( options && typeof options.mixins === 'object' ) { + + // Set any specified mixin functions on the response + Object.keys( options.mixins ).forEach(function( key ) { + var mixin = options.mixins[ key ]; + + // Will not overwrite existing methods + if ( typeof mixin === 'function' && ! EndpointRequest.prototype[ key ] ) { + EndpointRequest.prototype[ key ] = options.mixins[ key ]; + } + }); + } + + function endpointFactory( options ) { + /* jshint validthis:true */ + options = options || {}; + options = extend( options, this && this._options ); + return new EndpointRequest( options ); + } + endpointFactory.Ctor = EndpointRequest; + + return endpointFactory; +} + +module.exports = registerRoute; diff --git a/tests/.jshintrc b/tests/.jshintrc index 88f3dbb9..d4393b13 100644 --- a/tests/.jshintrc +++ b/tests/.jshintrc @@ -13,6 +13,7 @@ "globals": { "beforeEach": false, + "afterEach": false, "describe": false, "it": false } diff --git a/tests/integration/posts.js b/tests/integration/posts.js index d5e2b481..6fdef49d 100644 --- a/tests/integration/posts.js +++ b/tests/integration/posts.js @@ -8,6 +8,9 @@ var SUCCESS = 'success'; chai.use( require( 'chai-as-promised' ) ); var expect = chai.expect; +/*jshint -W079 */// Suppress warning about redefiniton of `Promise` +var Promise = require( 'bluebird' ); + var WP = require( '../../' ); var WPRequest = require( '../../lib/constructors/wp-request.js' ); @@ -361,7 +364,15 @@ describe( 'integration: posts()', function() { expect( post.title ).to.have.property( 'rendered' ); expect( post.title.rendered ).to.equal( 'Updated Title' ); // Re-authenticate & delete (trash) this post - return wp.posts().auth( credentials ).id( id ).delete(); + // Use a callback to exercise that part of the functionality + return new Promise(function( resolve, reject ) { + wp.posts().auth( credentials ).id( id ).delete(function( err, data ) { + if ( err ) { + return reject( err ); + } + resolve( data ); + }); + }); }).then(function( response ) { expect( response ).to.be.an( 'object' ); // DELETE action returns the post object diff --git a/tests/unit/lib/util/named-group-regexp.js b/tests/unit/lib/util/named-group-regexp.js new file mode 100644 index 00000000..74d93c68 --- /dev/null +++ b/tests/unit/lib/util/named-group-regexp.js @@ -0,0 +1,42 @@ +'use strict'; +var expect = require( 'chai' ).expect; + +var namedGroupRE = require( '../../../../lib/util/named-group-regexp' ); + +describe( 'named PCRE group RegExp', function() { + + it( 'is a regular expression', function() { + expect( namedGroupRE ).to.be.an.instanceof( RegExp ); + }); + + it( 'will not match an arbitrary string', function() { + var pathComponent = 'author'; + var result = pathComponent.match( namedGroupRE ); + expect( result ).to.be.null; + }); + + it( 'identifies the name and RE pattern for a PCRE named group', function() { + var pathComponent = '(?P[\\d]+)'; + var result = pathComponent.match( namedGroupRE ); + expect( result ).not.to.be.null; + expect( result[ 1 ] ).to.equal( 'parent' ); + expect( result[ 2 ] ).to.equal( '[\\d]+' ); + }); + + it( 'identifies the name and RE pattern for another group', function() { + var pathComponent = '(?P\\d+)'; + var result = pathComponent.match( namedGroupRE ); + expect( result ).not.to.be.null; + expect( result[ 1 ] ).to.equal( 'id' ); + expect( result[ 2 ] ).to.equal( '\\d+' ); + }); + + it( 'will match an empty string if a "RE Pattern" if the pattern is omitted', function() { + var pathComponent = '(?P)'; + var result = pathComponent.match( namedGroupRE ); + expect( result ).not.to.be.null; + expect( result[ 1 ] ).to.equal( 'id' ); + expect( result[ 2 ] ).to.equal( '' ); + }); + +}); diff --git a/tests/unit/lib/wp-register-route.js b/tests/unit/lib/wp-register-route.js new file mode 100644 index 00000000..ad4a6e2c --- /dev/null +++ b/tests/unit/lib/wp-register-route.js @@ -0,0 +1,356 @@ +'use strict'; +var chai = require( 'chai' ); +var expect = chai.expect; +chai.use( require( 'sinon-chai' ) ); +var sinon = require( 'sinon' ); + +var WPRequest = require( '../../../lib/constructors/wp-request' ); +var registerRoute = require( '../../../lib/wp-register-route' ); + +describe( 'wp.registerRoute', function() { + + it( 'is a function', function() { + expect( registerRoute ).to.be.a( 'function' ); + }); + + it( 'returns a function', function() { + expect( registerRoute( 'a', 'b' ) ).to.be.a( 'function' ); + }); + + it( 'sets a Ctor property on the returned function', function() { + var result = registerRoute( 'a', 'b' ); + expect( result ).to.have.property( 'Ctor' ); + }); + + it( 'returns a factory that returns Ctor instances', function() { + var result = registerRoute( 'a', 'b' ); + expect( result() ).to.be.an.instanceOf( result.Ctor ); + }); + + it( 'returns a factory for an object which extends WPRequest', function() { + var result = registerRoute( 'a', 'b' ); + expect( result() ).to.be.an.instanceOf( WPRequest ); + }); + + // custom route example for wp-api.org + describe( 'handler for /author/(?P\\d+)', function() { + var handler; + + beforeEach(function() { + var factory = registerRoute( 'myplugin/v1', '/author/(?P\\d+)' ); + handler = factory({ + endpoint: '/' + }); + }); + + it( 'renders a route prefixed with the provided namespace', function() { + expect( handler._renderURI().match( /myplugin\/v1/ ) ).to.be.ok; + }); + + it( 'sets the /authors/ path part automatically', function() { + expect( handler._renderURI() ).to.equal( '/myplugin/v1/author' ); + }); + + describe( '.id() method', function() { + + it( 'is defined', function() { + expect( handler ).to.have.property( 'id' ); + }); + + it( 'is a function', function() { + expect( handler.id ).to.be.a( 'function' ); + }); + + it( 'sets the ID component of the path', function() { + expect( handler.id( 3263827 )._renderURI() ).to.equal( '/myplugin/v1/author/3263827' ); + }); + + }); + + }); + + // custom route example for wp-api.org + describe( 'handler for /a/(?P\\d+)', function() { + var handler; + + beforeEach(function() { + var factory = registerRoute( 'ns', '/a/(?P\\d+)' ); + handler = factory({ + endpoint: '/' + }); + }); + + it( 'camelCases the setter name', function() { + expect( handler ).not.to.have.property( 'snake_cased_path_setter' ); + expect( handler ).to.have.property( 'snakeCasedPathSetter' ); + expect( handler.snakeCasedPathSetter ).to.be.a( 'function' ); + }); + + }); + + // custom route example for wp-api.org + describe( 'handler for route with capture group named identically to existing method', function() { + var sinonSandbox; + var handler; + + beforeEach(function() { + // Stub warn BEFORE we call registerRoute() + sinonSandbox = sinon.sandbox.create(); + sinonSandbox.stub( global.console, 'warn' ); + + var factory = registerRoute( 'ns', '/route/(?P)' ); + handler = factory({ + endpoint: '/' + }); + }); + + afterEach(function() { + // Restore sandbox + sinonSandbox.restore(); + }); + + it( 'overwrites the preexisting method, but logs a warning', function() { + // expect( handler.param ).to.equal( WPRequest.prototype.param ); + expect( handler.param( 'foo', 'bar' )._renderURI() ).to.equal( '/ns/route?foo=bar' ); + expect( handler.param( 'foo', 'bar' )._renderURI() ).not.to.equal( '/ns/route/foo' ); + expect( console.warn ).to.have.been.calledWith( 'Warning: method .param() is already defined!' ); + expect( console.warn ).to.have.been.calledWith( 'Cannot overwrite .route().param() method' ); + }); + + }); + + describe( 'mixins', function() { + var handler; + + beforeEach(function() { + var factory = registerRoute( 'myplugin/v1', '/author/(?P\\d+)', { + mixins: { + foo: function() { + return this.param( 'foo', true ); + }, + bar: function( val ) { + return this.param( 'bar', val ); + } + } + }); + handler = factory({ + endpoint: '/' + }); + }); + + it( 'are set on the prototype of the handler constructor', function() { + expect( handler ).to.have.property( 'foo' ); + expect( handler ).not.to.have.ownProperty( 'foo' ); + expect( handler.foo ).to.be.a( 'function' ); + expect( handler ).to.have.property( 'bar' ); + expect( handler ).not.to.have.ownProperty( 'bar' ); + expect( handler.bar ).to.be.a( 'function' ); + }); + + it( 'can set URL query parameters', function() { + expect( handler.foo()._renderURI() ).to.equal( '/myplugin/v1/author?foo=true' ); + }); + + it( 'can set dynamic URL query parameter values', function() { + expect( handler.bar( '1138' )._renderURI() ).to.equal( '/myplugin/v1/author?bar=1138' ); + }); + + it( 'will not overwrite existing endpoint handler prototype methods', function() { + var factory = registerRoute( 'myplugin/v1', '/author/(?P\\d+)', { + mixins: { + id: function() { + return this.param( 'id', 'as_a_param' ); + } + } + }); + var result = factory({ + endpoint: '/' + }).id( 7 )._renderURI(); + expect( result ).not.to.equal( '/myplugin/v1/author?id=as_a_param' ); + expect( result ).to.equal( '/myplugin/v1/author/7' ); + }); + + }); + + describe( 'handler for multi-capture group route', function() { + var handler; + + beforeEach(function() { + var factory = registerRoute( 'wp/v2', 'pages/(?P[\\d]+)/revisions/(?P[\\d]+)' ); + handler = factory({ + endpoint: '/' + }); + }); + + it( 'sets the first static level of the route automatically', function() { + expect( handler._renderURI() ).to.equal( '/wp/v2/pages' ); + }); + + it( 'permits the first dynamic level of the route to be set with .parent', function() { + expect( handler.parent( 79 )._renderURI() ).to.equal( '/wp/v2/pages/79' ); + }); + + it( 'permits the second static level of the route to be set with .revisions', function() { + expect( handler.parent( 79 ).revisions()._renderURI() ).to.equal( '/wp/v2/pages/79/revisions' ); + }); + + it( 'permits the second dynamic level of the route to be set with .id', function() { + expect( handler.parent( 79 ).revisions().id( 97 )._renderURI() ).to.equal( '/wp/v2/pages/79/revisions/97' ); + }); + + it( 'throws an error if the parts of the route provided are not contiguous', function() { + expect(function() { + handler.parent( 101 ).id( 102 )._renderURI(); + }).to.throw(); + }); + + }); + + describe( 'handler validation', function() { + var handler; + + it( 'can be enforced by providing a regex for a capture group', function() { + var factory = registerRoute( 'myplugin', 'one/(?P\\w+_\\d+)' ); + handler = factory({ + endpoint: '/' + }); + expect(function() { + handler.a( 'foo' )._renderURI(); + }).to.throw; + expect( handler.a( 'foo_100' )._renderURI() ).to.equal( '/myplugin/one/foo_100' ); + }); + + it( 'can be bypassed if no regex is provided for a capture group', function() { + var factory = registerRoute( 'myplugin', 'one/(?P)/two/(?P)' ); + handler = factory({ + endpoint: '/' + }); + expect(function() { + handler.a( 'foo' ).two().b( 1000 )._renderURI(); + }).not.to.throw; + expect( handler.a( 'foo' ).two( 1000 )._renderURI() ).to.equal( '/myplugin/one/foo/two/1000' ); + }); + + }); + + describe( 'method option:', function() { + var handler; + + beforeEach(function() { + var factory = registerRoute( 'myplugin', 'one/(?P)/(?P)', { + methods: [ 'GET', 'POST' ] + }); + handler = factory({ + endpoint: '/' + }); + }); + + describe( 'leaf nodes', function() { + + describe( 'support whitelisted method', function() { + + [ 'get', 'post' ].forEach(function( method ) { + it( method, function() { + expect(function() { + handler.a( 1 ).b( 2 )._checkMethodSupport( method ); + }).not.to.throw(); + }); + }); + + }); + + describe( 'blacklist method', function() { + + [ 'delete', 'put' ].forEach(function( method ) { + it( method, function() { + expect(function() { + handler.a( 1 ).b( 2 )._checkMethodSupport( method ); + }).to.throw(); + }); + }); + + }); + + it( 'support "head" implicitly if "get" is whitelisted', function() { + expect(function() { handler.a( 1 ).b( 2 )._checkMethodSupport( 'head' ); }).not.to.throw(); + }); + + it( 'support "get" implicitly if "head" is whitelisted', function() { + var factory = registerRoute( 'myplugin', 'one/(?P)/(?P)', { + methods: [ 'HEAD' ] + }); + handler = factory({ + endpoint: '/' + }); + expect(function() { handler.a( 1 ).b( 2 )._checkMethodSupport( 'head' ); }).not.to.throw(); + }); + + }); + + describe( 'non-leaf nodes', function() { + + describe( 'support all methods', function() { + + [ 'get', 'post', 'head', 'put', 'delete' ].forEach(function( method ) { + it( method, function() { + expect(function() { + handler.a( 1 )._checkMethodSupport( method ); + }).not.to.throw(); + }); + }); + + }); + + }); + + describe( 'specified as a string', function() { + + beforeEach(function() { + var factory = registerRoute( 'myplugin', 'one/(?P)/(?P)', { + methods: 'POST' + }); + handler = factory({ + endpoint: '/' + }); + }); + + it( 'is properly whitelisted', function() { + expect(function() { handler.a( 1 ).b( 2 )._checkMethodSupport( 'post' ); }).not.to.throw(); + }); + + describe( 'implicitly blacklists other method', function() { + + [ 'get', 'head', 'delete', 'put' ].forEach(function( method ) { + it( method, function() { + expect(function() { + handler.a( 1 ).b( 2 )._checkMethodSupport( method ); + }).to.throw(); + }); + }); + + }); + + }); + + }); + + describe( 'handler options', function() { + + it( 'can be passed in to the factory method', function() { + var factory = registerRoute( 'myplugin', 'myroute' ); + expect( factory({ endpoint: '/wp-yaml/' })._renderURI() ).to.equal( '/wp-yaml/myplugin/myroute' ); + }); + + it( 'correctly defaults to the containing object\'s _options, if present', function() { + var obj = { + factory: registerRoute( 'myplugin', 'myroute' ), + _options: { + endpoint: '/foo/' + } + }; + expect( obj.factory()._renderURI() ).to.equal( '/foo/myplugin/myroute' ); + }); + + }); + +}); diff --git a/wp.js b/wp.js index ce5b31bf..dd5c6abc 100644 --- a/wp.js +++ b/wp.js @@ -131,4 +131,7 @@ WP.prototype.root = function( relativePath ) { return request; }; +// Apply the registerRoute method to the prototype +WP.prototype.registerRoute = require( './lib/wp-register-route' ); + module.exports = WP;