diff --git a/bin/jekyll b/bin/jekyll deleted file mode 100755 index 1b0a8d32..00000000 --- a/bin/jekyll +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/sh -':' //; exec "$(command -v nodejs || command -v node)" "$0" "$@" -// ^^^ Lovely polyglot script to permit usage via node _or_ via bash: see -// http://unix.stackexchange.com/questions/65235/universal-node-js-shebang -/** - * This script will start the Jekyll server in the context of the docs - * directory. It is only for use in local development, and sets the --baseurl - * option to override the production-only baseurl in _config.yml. - */ -'use strict'; - -const path = require( 'path' ); -const spawn = require( 'child_process' ).spawn; -const argv = require( 'minimist' )( process.argv.slice( 2 ) ); - -// Execute within the context of the docs directory -const docsDir = path.resolve( __dirname, '../documentation' ); - -if ( argv.install || argv.i ) { - // Install the ruby bundle needed to run jekyll - const server = spawn( 'bundle', [ 'install' ], { - cwd: docsDir, - stdio: 'inherit' - }); - - server.on( 'error', err => console.error( err ) ); -} else { - // Start the server in local dev mode - const bundleOptions = [ 'exec', 'jekyll', 'serve', '--baseurl', '' ]; - if ( argv.host ) { - bundleOptions.push( '--host', argv.host ); - } - - const server = spawn( 'bundle', bundleOptions, { - cwd: docsDir, - stdio: 'inherit' - }); - - server.on( 'error', err => console.error( err ) ); -} diff --git a/bin/jekyll.js b/bin/jekyll.js new file mode 100755 index 00000000..4b94ed08 --- /dev/null +++ b/bin/jekyll.js @@ -0,0 +1,37 @@ +/** + * This script will start the Jekyll server in the context of the docs + * directory. It is only for use in local development, and sets the --baseurl + * option to override the production-only baseurl in _config.yml. + */ +/* eslint-disable no-console */ +'use strict'; + +const path = require( 'path' ); +const spawn = require( 'child_process' ).spawn; +const argv = require( 'minimist' )( process.argv.slice( 2 ) ); + +// Execute within the context of the docs directory +const docsDir = path.resolve( __dirname, '../documentation' ); + +if ( argv.install || argv.i ) { + // Install the ruby bundle needed to run jekyll + const server = spawn( 'bundle', [ 'install' ], { + cwd: docsDir, + stdio: 'inherit', + } ); + + server.on( 'error', err => console.error( err ) ); +} else { + // Start the server in local dev mode + const bundleOptions = [ 'exec', 'jekyll', 'serve', '--baseurl', '' ]; + if ( argv.host ) { + bundleOptions.push( '--host', argv.host ); + } + + const server = spawn( 'bundle', bundleOptions, { + cwd: docsDir, + stdio: 'inherit', + } ); + + server.on( 'error', err => console.error( err ) ); +} diff --git a/fetch/README.md b/fetch/README.md new file mode 100644 index 00000000..07cd2a92 --- /dev/null +++ b/fetch/README.md @@ -0,0 +1,14 @@ +# `wpapi/fetch` + +This endpoint returns a version of the WPAPI library configured to use Fetch for HTTP requests. + +## Installation & Usage + +Install both `wpapi` and `isomorphic-unfetch` using the command `npm install --save wpapi isomorphic-unfetch`. + +```js +import WPAPI from 'wpapi/fetch'; + +// Configure and use WPAPI as normal +const site = new WPAPI( { /* ... */ } ); +``` diff --git a/fetch/fetch-transport.js b/fetch/fetch-transport.js new file mode 100644 index 00000000..023f1af1 --- /dev/null +++ b/fetch/fetch-transport.js @@ -0,0 +1,271 @@ +/** + * @module fetch-transport + */ +'use strict'; + +const fetch = require( 'node-fetch' ); +const FormData = require( 'form-data' ); +const fs = require( 'fs' ); + +const objectReduce = require( '../lib/util/object-reduce' ); +const { createPaginationObject } = require( '../lib/pagination' ); + +/** + * Utility method to set a header value on a fetch configuration object. + * + * @method _setHeader + * @private + * @param {Object} config A configuration object of unknown completeness + * @param {string} header String name of the header to set + * @param {string} value Value of the header to set + * @returns {Object} The modified configuration object + */ +const _setHeader = ( config, header, value ) => ( { + ...config, + headers: { + ...( config && config.headers ? config.headers : null ), + [ header ]: value, + }, +} ); + +/** + * Set any provided headers on the outgoing request object. Runs after _auth. + * + * @method _setHeaders + * @private + * @param {Object} config A fetch request configuration object + * @param {Object} options A WPRequest _options object + * @param {Object} A fetch config object, with any available headers set + */ +function _setHeaders( config, options ) { + // If there's no headers, do nothing + if ( ! options.headers ) { + return config; + } + + return objectReduce( + options.headers, + ( config, value, key ) => _setHeader( config, key, value ), + config, + ); +} + +/** + * Conditionally set basic or nonce authentication on a server request object. + * + * @method _auth + * @private + * @param {Object} config A fetch request configuration object + * @param {Object} options A WPRequest _options object + * @param {Boolean} forceAuthentication whether to force authentication on the request + * @param {Object} A fetch request object, conditionally configured to use basic auth + */ +function _auth( config, options, forceAuthentication ) { + // If we're not supposed to authenticate, don't even start + if ( ! forceAuthentication && ! options.auth && ! options.nonce ) { + return config; + } + + // Enable nonce in options for Cookie authentication http://wp-api.org/guides/authentication.html + if ( options.nonce ) { + config.credentials = 'same-origin'; + return _setHeader( config, 'X-WP-Nonce', options.nonce ); + } + + // If no username or no password, can't authenticate + if ( ! options.username || ! options.password ) { + return config; + } + + // Can authenticate: set basic auth parameters on the config + let authorization = `${ options.username }:${ options.password }`; + if ( global.Buffer ) { + authorization = global.Buffer.from( authorization ).toString( 'base64' ); + } else if ( global.btoa ) { + authorization = global.btoa( authorization ); + } + + return _setHeader( config, 'Authorization', `Basic ${ authorization }` ); +} + +// HTTP-Related Helpers +// ==================== + +/** + * Get the response headers as a regular JavaScript object. + * + * @param {Object} response Fetch response object. + */ +function getHeaders( response ) { + const headers = {}; + response.headers.forEach( ( value, key ) => { + headers[ key ] = value; + } ); + return headers; +} + +/** + * Return the body of the request, augmented with pagination information if the + * result is a paged collection. + * + * @private + * @param {WPRequest} wpreq The WPRequest representing the returned HTTP response + * @param {Object} response The fetch response object for the HTTP call + * @returns {Object} The JSON data of the response, conditionally augmented with + * pagination information if the response is a partial collection. + */ +const parseFetchResponse = ( response, wpreq ) => { + // Check if an HTTP error occurred. + if ( ! response.ok ) { + // Extract and return the API-provided error object if the response is + // not ok, i.e. if the error was from the API and not internal to fetch. + return response.json().then( ( err ) => { + // Throw the error object to permit proper error handling. + throw err; + }, () => { + // JSON serialization failed; throw the underlying response. + throw response; + } ); + } + + // If the response is OK, process & return the JSON data. + return response.json().then( ( body ) => { + // Construct a response the pagination helper can understand. + const mockResponse = { + headers: getHeaders( response ), + }; + + const _paging = createPaginationObject( mockResponse, wpreq._options, wpreq.transport ); + if ( _paging ) { + body._paging = _paging; + } + return body; + } ); +}; + +// HTTP Methods: Private HTTP-verb versions +// ======================================== + +const send = ( wpreq, config ) => fetch( + wpreq.toString(), + _setHeaders( _auth( config, wpreq._options ), wpreq._options ) +).then( ( response ) => { + // return response.headers.get( 'Link' ); + return parseFetchResponse( response, wpreq ); +} ); + +/** + * @method get + * @async + * @param {WPRequest} wpreq A WPRequest query object + * @returns {Promise} A promise to the results of the HTTP request + */ +function _httpGet( wpreq ) { + return send( wpreq, { + method: 'GET', + } ); +} + +/** + * Invoke an HTTP "POST" request against the provided endpoint + * @method post + * @async + * @param {WPRequest} wpreq A WPRequest query object + * @param {Object} data The data for the POST request + * @returns {Promise} A promise to the results of the HTTP request + */ +function _httpPost( wpreq, data = {} ) { + let file = wpreq._attachment; + if ( file ) { + // Handle files provided as a path string + if ( typeof file === 'string' ) { + file = fs.createReadStream( file ); + } + + // Build the form data object + const form = new FormData(); + form.append( 'file', file, wpreq._attachmentName ); + Object.keys( data ).forEach( key => form.append( key, data[ key ] ) ); + + // Fire off the media upload request + return send( wpreq, { + method: 'POST', + redirect: 'follow', + body: form, + } ); + } + + return send( wpreq, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + body: JSON.stringify( data ), + } ); +} + +/** + * @method put + * @async + * @param {WPRequest} wpreq A WPRequest query object + * @param {Object} data The data for the PUT request + * @returns {Promise} A promise to the results of the HTTP request + */ +function _httpPut( wpreq, data = {} ) { + return send( wpreq, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + body: JSON.stringify( data ), + } ); +} + +/** + * @method delete + * @async + * @param {WPRequest} wpreq A WPRequest query object + * @param {Object} [data] Data to send along with the DELETE request + * @returns {Promise} A promise to the results of the HTTP request + */ +function _httpDelete( wpreq, data ) { + const config = { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + }; + + if ( data ) { + config.body = JSON.stringify( data ); + } + + return send( wpreq, config ); +} + +/** + * @method head + * @async + * @param {WPRequest} wpreq A WPRequest query object + * @returns {Promise} A promise to the header results of the HTTP request + */ +function _httpHead( wpreq ) { + const url = wpreq.toString(); + const config = _setHeaders( _auth( { + method: 'HEAD', + }, wpreq._options, true ), wpreq._options ); + + return fetch( url, config ) + .then( response => getHeaders( response ) ); +} + +module.exports = { + delete: _httpDelete, + get: _httpGet, + head: _httpHead, + post: _httpPost, + put: _httpPut, +}; diff --git a/fetch/index.js b/fetch/index.js new file mode 100644 index 00000000..b9403e54 --- /dev/null +++ b/fetch/index.js @@ -0,0 +1,6 @@ +const WPAPI = require( '../wpapi' ); +const fetchTransport = require( './fetch-transport' ); +const bindTransport = require( '../lib/bind-transport' ); + +// Bind the fetch-based HTTP transport to the WPAPI constructor +module.exports = bindTransport( WPAPI, fetchTransport ); diff --git a/fetch/tests/.eslintrc.js b/fetch/tests/.eslintrc.js new file mode 100644 index 00000000..bd527d76 --- /dev/null +++ b/fetch/tests/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + 'env': { + jest: true, + }, +}; diff --git a/fetch/tests/unit/fetch.js b/fetch/tests/unit/fetch.js new file mode 100644 index 00000000..ba37ae8e --- /dev/null +++ b/fetch/tests/unit/fetch.js @@ -0,0 +1,164 @@ +'use strict'; + +const WPAPI = require( '../..' ); + +// HTTP transport, for stubbing +const fetchTransport = require( '../../fetch-transport' ); + +// Variable to use as our "success token" in promise assertions +const SUCCESS = 'success'; + +describe( 'WPAPI', () => { + + describe( 'constructor', () => { + + describe( 'assigns default HTTP transport', () => { + + it( 'for GET requests', () => { + jest.spyOn( fetchTransport, 'get' ).mockImplementation( () => {} ); + const site = new WPAPI( { + endpoint: 'http://some.url.com/wp-json', + } ); + const query = site.root( '' ); + query.get(); + expect( fetchTransport.get ).toHaveBeenCalledWith( query ); + fetchTransport.get.mockRestore(); + } ); + + it( 'for POST requests', () => { + jest.spyOn( fetchTransport, 'post' ).mockImplementation( () => {} ); + const site = new WPAPI( { + endpoint: 'http://some.url.com/wp-json', + } ); + const query = site.root( '' ); + const data = {}; + query.create( data ); + expect( fetchTransport.post ).toHaveBeenCalledWith( query, data ); + fetchTransport.post.mockRestore(); + } ); + + it( 'for POST requests', () => { + jest.spyOn( fetchTransport, 'post' ).mockImplementation( () => {} ); + const site = new WPAPI( { + endpoint: 'http://some.url.com/wp-json', + } ); + const query = site.root( '' ); + const data = {}; + query.create( data ); + expect( fetchTransport.post ).toHaveBeenCalledWith( query, data ); + fetchTransport.post.mockRestore(); + } ); + + it( 'for PUT requests', () => { + jest.spyOn( fetchTransport, 'put' ).mockImplementation( () => {} ); + const site = new WPAPI( { + endpoint: 'http://some.url.com/wp-json', + } ); + const query = site.root( 'a-resource' ); + const data = {}; + query.update( data ); + expect( fetchTransport.put ).toHaveBeenCalledWith( query, data ); + fetchTransport.put.mockRestore(); + } ); + + it( 'for DELETE requests', () => { + jest.spyOn( fetchTransport, 'delete' ).mockImplementation( () => {} ); + const site = new WPAPI( { + endpoint: 'http://some.url.com/wp-json', + } ); + const query = site.root( 'a-resource' ); + const data = { + force: true, + }; + query.delete( data ); + expect( fetchTransport.delete ).toHaveBeenCalledWith( query, data ); + fetchTransport.delete.mockRestore(); + } ); + + } ); + + } ); + + describe( '.transport constructor property', () => { + + it( 'is defined', () => { + expect( WPAPI ).toHaveProperty( 'transport' ); + } ); + + it( 'is an object', () => { + expect( typeof WPAPI.transport ).toBe( 'object' ); + } ); + + it( 'has methods for each http transport action', () => { + expect( typeof WPAPI.transport.delete ).toBe( 'function' ); + expect( typeof WPAPI.transport.get ).toBe( 'function' ); + expect( typeof WPAPI.transport.head ).toBe( 'function' ); + expect( typeof WPAPI.transport.post ).toBe( 'function' ); + expect( typeof WPAPI.transport.put ).toBe( 'function' ); + } ); + + it( 'is frozen (properties cannot be modified directly)', () => { + expect( () => { + WPAPI.transport.get = () => {}; + } ).toThrow(); + } ); + + } ); + + describe( '.discover() constructor method', () => { + + beforeEach( () => { + jest.spyOn( fetchTransport, 'get' ).mockImplementation( () => {} ); + } ); + + afterEach( () => { + fetchTransport.get.mockRestore(); + } ); + + it( 'is a function', () => { + expect( WPAPI ).toHaveProperty( 'discover' ); + expect( typeof WPAPI.discover ).toBe( 'function' ); + } ); + + it( 'discovers the API root with a GET request', () => { + const url = 'http://mozarts.house'; + fetchTransport.get.mockImplementation( () => Promise.resolve( { + name: 'Skip Beats', + descrition: 'Just another WordPress weblog', + routes: { + '/': { + _links: { + self: 'http://mozarts.house/wp-json/', + }, + }, + 'list': {}, + 'of': {}, + 'routes': {}, + }, + } ) ); + const prom = WPAPI.discover( url ) + .then( ( result ) => { + expect( result ).toBeInstanceOf( WPAPI ); + expect( result.root().toString() ).toBe( 'http://mozarts.house/wp-json/' ); + expect( fetchTransport.get ).toBeCalledTimes( 1 ); + const indexRequestObject = fetchTransport.get.mock.calls[0][0]; + expect( indexRequestObject.toString() ).toBe( 'http://mozarts.house/?rest_route=%2F' ); + return SUCCESS; + } ); + return expect( prom ).resolves.toBe( SUCCESS ); + } ); + + it( 'throws an error if no API endpoint can be discovered', () => { + const url = 'http://we.made.it/to/mozarts/house'; + fetchTransport.get.mockImplementationOnce( () => Promise.reject( 'Some error' ) ); + const prom = WPAPI.discover( url ) + .catch( ( err ) => { + expect( err ).toBe( 'Some error' ); + return SUCCESS; + } ); + return expect( prom ).resolves.toBe( SUCCESS ); + } ); + + } ); + +} ); diff --git a/lib/bind-transport.js b/lib/bind-transport.js index 2016d239..0fbfd86a 100644 --- a/lib/bind-transport.js +++ b/lib/bind-transport.js @@ -1,13 +1,58 @@ /** - * Utility method for binding a frozen transport object to the WPAPI constructor + * Return a new constructor combining the path-building logic of WPAPI with + * a specified HTTP transport layer * - * See /axios and /superagent directories - * @param {Function} WPAPI The WPAPI constructor + * This new constructor receives the .discover() static method, and the base + * constructor's .site() static method is overridden to return instances of + * the new transport-specific constructor + * + * See /fetch and /superagent directories + * + * @param {Function} QueryBuilder The base WPAPI query builder constructor * @param {Object} httpTransport The HTTP transport object - * @returns {Function} The WPAPI object augmented with the provided transport + * @returns {Function} A WPAPI constructor with an associated HTTP transport */ -module.exports = function( WPAPI, httpTransport ) { +module.exports = function( QueryBuilder, httpTransport ) { + + // Create a new constructor which inherits from WPAPI, but uses this transport + class WPAPI extends QueryBuilder {} + WPAPI.transport = Object.create( httpTransport ); Object.freeze( WPAPI.transport ); + + // Add a version of the base WPAPI.site() static method specific to this new constructor + WPAPI.site = ( endpoint, routes ) => { + return new WPAPI( { + endpoint: endpoint, + routes: routes, + } ); + }; + + /** + * 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 WPAPI instance bound to the provided site. + * + * @memberof! WPAPI + * @static + * @param {string} url A URL within a REST API-enabled WordPress website + * @returns {Promise} A promise that resolves to a configured WPAPI instance bound + * to the deduced endpoint, or rejected if an endpoint is not found or the + * library is unable to parse the provided endpoint. + */ + WPAPI.discover = ( url ) => { + // Use WPAPI.site to make a request using the defined transport + const req = WPAPI.site( url ).root().param( 'rest_route', '/' ); + return req.get().then( ( apiRootJSON ) => { + const routes = apiRootJSON.routes; + return new WPAPI( { + // Derive the endpoint from the self link for the / root + endpoint: routes['/']._links.self, + // Bootstrap returned WPAPI instance with the discovered routes + routes: routes, + } ); + } ); + }; + return WPAPI; }; diff --git a/lib/pagination.js b/lib/pagination.js new file mode 100644 index 00000000..6d1a5db1 --- /dev/null +++ b/lib/pagination.js @@ -0,0 +1,87 @@ +const parseLinkHeader = require( 'li' ).parse; + +const WPRequest = require( '../lib/constructors/wp-request' ); + +/** + * If the response is not paged, return the body as-is. If pagination + * information is present in the response headers, parse those headers into + * a custom `_paging` property on the response body. `_paging` contains links + * to the previous and next pages in the collection, as well as metadata + * about the size and number of pages in the collection. + * + * The structure of the `_paging` property is as follows: + * + * - `total` {Integer} The total number of records in the collection + * - `totalPages` {Integer} The number of pages available + * - `links` {Object} The parsed "links" headers, separated into individual URI strings + * - `next` {WPRequest} A WPRequest object bound to the "next" page (if page exists) + * - `prev` {WPRequest} A WPRequest object bound to the "previous" page (if page exists) + * + * @private + * @param {Object} result The response object from the HTTP request + * @param {Object} options The options hash from the original request + * @param {String} options.endpoint The base URL of the requested API endpoint + * @param {Object} httpTransport The HTTP transport object used by the original request + * @returns {Object} The pagination metadata object for this HTTP request, or else null + */ +function createPaginationObject( result, options, httpTransport ) { + let _paging = null; + + if ( ! result.headers ) { + // No headers: return as-is + return _paging; + } + + // Guard against capitalization inconsistencies in returned headers + Object.keys( result.headers ).forEach( ( header ) => { + result.headers[ header.toLowerCase() ] = result.headers[ header ]; + } ); + + if ( ! result.headers[ 'x-wp-totalpages' ] ) { + // No paging: return as-is + return _paging; + } + + const totalPages = +result.headers[ 'x-wp-totalpages' ]; + + if ( ! totalPages || totalPages === 0 ) { + // No paging: return as-is + return _paging; + } + + // Decode the link header object + const links = result.headers.link ? + parseLinkHeader( result.headers.link ) : + {}; + + // Store pagination data from response headers on the response collection + _paging = { + total: +result.headers[ 'x-wp-total' ], + totalPages: totalPages, + links: links, + }; + + // Create a WPRequest instance pre-bound to the "next" page, if available + if ( links.next ) { + _paging.next = new WPRequest( { + ...options, + transport: httpTransport, + endpoint: links.next, + } ); + } + + // Create a WPRequest instance pre-bound to the "prev" page, if available + if ( links.prev ) { + _paging.prev = new WPRequest( { + ...options, + transport: httpTransport, + endpoint: links.prev, + } ); + } + + return _paging; +} + +module.exports = { + createPaginationObject, +}; diff --git a/lib/util/object-reduce.js b/lib/util/object-reduce.js index 83e13aee..a6f7a0d7 100644 --- a/lib/util/object-reduce.js +++ b/lib/util/object-reduce.js @@ -8,7 +8,7 @@ * minification and ~12kb of savings with minification. * * Unlike lodash.reduce(), the iterator and initial value properties are NOT - * optional: this is done to simplify the code, this module is not intended to + * optional: this is done to simplify the code. This module is not intended to * be a full replacement for lodash.reduce and instead prioritizes simplicity * for a specific common case. * diff --git a/node-wpapi.php b/node-wpapi.php new file mode 100644 index 00000000..24eb81a2 --- /dev/null +++ b/node-wpapi.php @@ -0,0 +1,61 @@ + esc_url_raw( rest_url() ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ] + ); +} + +/** + * Read & parse a JSON file. + * + * @param string $path The path to the JSON file to load. + * @return array The parsed JSON data object. + */ +function read_json( string $path ) : array { + if ( ! file_exists( $path ) ) { + return []; + } + $contents = file_get_contents( $path ); + if ( empty( $contents ) ) { + return []; + } + return json_decode( $contents, true ); +} diff --git a/package.json b/package.json index 3345d68d..4df37586 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "lint": "npm run eslint", "watch": "jest --watch", "test:all": "jest", - "test:unit": "jest tests/unit superagent/tests/unit", + "test:unit": "jest tests/unit superagent/tests/unit fetch/tests/unit", "test:integration": "jest tests/integration", "test:ci": "npm run eslint && jest tests/unit --coverage", "pretest": "npm run lint || true", @@ -62,34 +62,34 @@ "dependencies": { "li": "^1.3.0", "parse-link-header": "^1.0.1", - "qs": "^6.6.0" + "qs": "^6.7.0" }, "optionalDependencies": { + "form-data": "^2.5.0", + "node-fetch": "^2.6.0", "superagent": "^4.1.0" }, "devDependencies": { - "@babel/core": "^7.2.2", + "@babel/core": "^7.5.0", "@babel/plugin-proposal-object-rest-spread": "^7.2.0", - "@babel/preset-env": "^7.2.3", - "babel-loader": "^8.0.4", + "@babel/preset-env": "^7.5.0", + "babel-loader": "^8.0.6", "combyne": "^2.0.0", "eslint": "^4.19.1", - "grunt": "^1.0.1", + "grunt": "^1.0.4", "grunt-cli": "^1.2.0", "grunt-contrib-clean": "^1.0.0", "grunt-zip": "^0.17.1", - "istanbul": "^0.4.4", - "jest": "^23.6.0", - "jsdoc": "^3.4.3", + "jest": "^24.8.0", + "jsdoc": "^3.6.2", "kramed": "^0.5.6", "load-grunt-tasks": "^3.5.0", - "lodash.reduce": "^4.6.0", "minami": "^1.2.3", "minimist": "^1.2.0", "prompt": "^1.0.0", - "rimraf": "^2.6.1", - "webpack": "^4.28.1", - "webpack-bundle-analyzer": "^3.0.3", - "webpack-cli": "^3.1.2" + "rimraf": "^2.6.3", + "webpack": "^4.35.0", + "webpack-bundle-analyzer": "^3.3.2", + "webpack-cli": "^3.3.5" } } diff --git a/superagent/superagent-transport.js b/superagent/superagent-transport.js index c22dc435..36057d02 100644 --- a/superagent/superagent-transport.js +++ b/superagent/superagent-transport.js @@ -4,12 +4,11 @@ 'use strict'; const agent = require( 'superagent' ); -const parseLinkHeader = require( 'li' ).parse; -const WPRequest = require( '../lib/constructors/wp-request' ); const checkMethodSupport = require( '../lib/util/check-method-support' ); const objectReduce = require( '../lib/util/object-reduce' ); const isEmptyObject = require( '../lib/util/is-empty-object' ); +const { createPaginationObject } = require( '../lib/pagination' ); /** * Set any provided headers on the outgoing request object. Runs after _auth. @@ -95,86 +94,6 @@ function extractResponseBody( response ) { return responseBody; } -/** - * If the response is not paged, return the body as-is. If pagination - * information is present in the response headers, parse those headers into - * a custom `_paging` property on the response body. `_paging` contains links - * to the previous and next pages in the collection, as well as metadata - * about the size and number of pages in the collection. - * - * The structure of the `_paging` property is as follows: - * - * - `total` {Integer} The total number of records in the collection - * - `totalPages` {Integer} The number of pages available - * - `links` {Object} The parsed "links" headers, separated into individual URI strings - * - `next` {WPRequest} A WPRequest object bound to the "next" page (if page exists) - * - `prev` {WPRequest} A WPRequest object bound to the "previous" page (if page exists) - * - * @private - * @param {Object} result The response object from the HTTP request - * @param {Object} options The options hash from the original request - * @param {String} options.endpoint The base URL of the requested API endpoint - * @param {Object} httpTransport The HTTP transport object used by the original request - * @returns {Object} The pagination metadata object for this HTTP request, or else null - */ -function createPaginationObject( result, options, httpTransport ) { - let _paging = null; - - if ( ! result.headers ) { - // No headers: return as-is - return _paging; - } - - // Guard against capitalization inconsistencies in returned headers - Object.keys( result.headers ).forEach( ( header ) => { - result.headers[ header.toLowerCase() ] = result.headers[ header ]; - } ); - - if ( ! result.headers[ 'x-wp-totalpages' ] ) { - // No paging: return as-is - return _paging; - } - - const totalPages = +result.headers[ 'x-wp-totalpages' ]; - - if ( ! totalPages || totalPages === 0 ) { - // No paging: return as-is - return _paging; - } - - // Decode the link header object - const links = result.headers.link ? - parseLinkHeader( result.headers.link ) : - {}; - - // Store pagination data from response headers on the response collection - _paging = { - total: +result.headers[ 'x-wp-total' ], - totalPages: totalPages, - links: links, - }; - - // Create a WPRequest instance pre-bound to the "next" page, if available - if ( links.next ) { - _paging.next = new WPRequest( { - ...options, - transport: httpTransport, - endpoint: links.next, - } ); - } - - // Create a WPRequest instance pre-bound to the "prev" page, if available - if ( links.prev ) { - _paging.prev = new WPRequest( { - ...options, - transport: httpTransport, - endpoint: links.prev, - } ); - } - - return _paging; -} - // HTTP-Related Helpers // ==================== diff --git a/tests/integration/autodiscovery.js b/tests/integration/autodiscovery.js index 9e38aad0..5af4a961 100644 --- a/tests/integration/autodiscovery.js +++ b/tests/integration/autodiscovery.js @@ -18,6 +18,7 @@ const expectedResults = { describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: discover', ( transportName, WPAPI ) => { let apiPromise; diff --git a/tests/integration/categories.js b/tests/integration/categories.js index 6c39c9e6..12a005fc 100644 --- a/tests/integration/categories.js +++ b/tests/integration/categories.js @@ -49,6 +49,7 @@ const expectedResults = { describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: categories()', ( transportName, WPAPI ) => { let wp; diff --git a/tests/integration/comments.js b/tests/integration/comments.js index 5d873d4e..9e7451b2 100644 --- a/tests/integration/comments.js +++ b/tests/integration/comments.js @@ -63,6 +63,7 @@ const getPostsAndAuthors = comments => comments describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: comments()', ( transportName, WPAPI ) => { let wp; diff --git a/tests/integration/custom-http-headers.js b/tests/integration/custom-http-headers.js index a61ff037..8ed132ea 100644 --- a/tests/integration/custom-http-headers.js +++ b/tests/integration/custom-http-headers.js @@ -11,6 +11,7 @@ const SUCCESS = 'success'; describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: custom HTTP Headers', ( transportName, WPAPI ) => { let wp; diff --git a/tests/integration/custom-http-transport.js b/tests/integration/custom-http-transport.js index 0748330d..876c248f 100644 --- a/tests/integration/custom-http-transport.js +++ b/tests/integration/custom-http-transport.js @@ -7,6 +7,7 @@ const SUCCESS = 'success'; describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ), require( '../../superagent/superagent-transport' ) ], + [ 'wpapi/fetch', require( '../../fetch' ), require( '../../fetch/fetch-transport' ) ], ] )( '%s: custom HTTP transport methods', ( transportName, WPAPI, httpTransport ) => { let wp; let id; diff --git a/tests/integration/error-states.js b/tests/integration/error-states.js index aa266c85..31b7abf7 100644 --- a/tests/integration/error-states.js +++ b/tests/integration/error-states.js @@ -5,14 +5,15 @@ const SUCCESS = 'success'; describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: error states:', ( transportName, WPAPI ) => { - it( 'invalid root endpoint causes a transport-level (superagent) 404 error', () => { + it( 'invalid root endpoint causes a transport-level 404 error', () => { const wp = WPAPI.site( 'http://wpapi.local/wrong-root-endpoint' ); const prom = wp.posts() .get() .catch( ( err ) => { - expect( err ).toBeInstanceOf( Error ); + // expect( err ).toBeInstanceOf( Error ); expect( err ).toHaveProperty( 'status' ); expect( err.status ).toBe( 404 ); return SUCCESS; diff --git a/tests/integration/media.js b/tests/integration/media.js index 41d450ce..c0432113 100644 --- a/tests/integration/media.js +++ b/tests/integration/media.js @@ -58,6 +58,7 @@ const expectedResults = { describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: media()', ( transportName, WPAPI ) => { let wp; let authenticated; diff --git a/tests/integration/pages.js b/tests/integration/pages.js index 4fd75acf..9554dc73 100644 --- a/tests/integration/pages.js +++ b/tests/integration/pages.js @@ -40,6 +40,7 @@ const expectedResults = { describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: pages()', ( transportName, WPAPI ) => { let wp; diff --git a/tests/integration/posts.js b/tests/integration/posts.js index 0764b2d1..42a91784 100644 --- a/tests/integration/posts.js +++ b/tests/integration/posts.js @@ -55,6 +55,7 @@ const expectedResults = { describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: posts()', ( transportName, WPAPI ) => { let wp; let authenticated; @@ -176,9 +177,8 @@ describe.each( [ .then( ( posts ) => { expect( Array.isArray( posts ) ).toBe( true ); - // @TODO: re-enable once PPP support is merged - // expect( posts.length ).toBe( 10 ); - // expect( getTitles( posts ) ).toEqual( expectedResults.titles.page2 ); + expect( posts.length ).toBe( 10 ); + expect( getTitles( posts ) ).toEqual( expectedResults.titles.page2 ); return SUCCESS; } ); return expect( prom ).resolves.toBe( SUCCESS ); @@ -220,8 +220,7 @@ describe.each( [ .page( 2 ) .get() .then( ( posts ) => { - // @TODO: re-enable once PPP support is merged - // expect( getTitles( posts ) ).toEqual( expectedResults.titles.page2 ); + expect( getTitles( posts ) ).toEqual( expectedResults.titles.page2 ); return posts._paging.prev .get() .then( ( posts ) => { diff --git a/tests/integration/settings.js b/tests/integration/settings.js index 3375ef94..b3bbc171 100644 --- a/tests/integration/settings.js +++ b/tests/integration/settings.js @@ -7,6 +7,7 @@ const SUCCESS = 'success'; describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: settings()', ( transportName, WPAPI ) => { let wp; let authenticated; diff --git a/tests/integration/tags.js b/tests/integration/tags.js index 8a0d4fba..d6cc1dbe 100644 --- a/tests/integration/tags.js +++ b/tests/integration/tags.js @@ -54,6 +54,7 @@ const expectedResults = { describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: tags()', ( transportName, WPAPI ) => { let wp; diff --git a/tests/integration/taxonomies.js b/tests/integration/taxonomies.js index 11463f5b..f3c04ca4 100644 --- a/tests/integration/taxonomies.js +++ b/tests/integration/taxonomies.js @@ -5,6 +5,7 @@ const SUCCESS = 'success'; describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: taxonomies()', ( transportName, WPAPI ) => { let wp; diff --git a/tests/integration/types.js b/tests/integration/types.js index 33e97423..a98fd166 100644 --- a/tests/integration/types.js +++ b/tests/integration/types.js @@ -5,6 +5,7 @@ const SUCCESS = 'success'; describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: types()', ( transportName, WPAPI ) => { let wp; diff --git a/tests/unit/lib/bind-transport.js b/tests/unit/lib/bind-transport.js new file mode 100644 index 00000000..6dc32b89 --- /dev/null +++ b/tests/unit/lib/bind-transport.js @@ -0,0 +1,35 @@ +'use strict'; + +const bindTransport = require( '../../../lib/bind-transport' ); + +describe( 'bindTransport()', () => { + it( 'is a function', () => { + expect( bindTransport ).toBeInstanceOf( Function ); + } ); +} ); + +describe( 'Transport-bound WPAPI constructor', () => { + let transport; + let WPAPI; + + beforeEach( () => { + transport = { + get: jest.fn(), + }; + WPAPI = bindTransport( require( '../../../wpapi' ), transport ); + } ); + + it( 'has a .site() static method', () => { + expect( WPAPI ).toHaveProperty( 'site' ); + expect( typeof WPAPI.site ).toBe( 'function' ); + } ); + + it( 'returns instances of the expected constructor from WPAPI.site', () => { + const site = WPAPI.site( 'endpoint/url' ); + expect( site instanceof WPAPI ).toBe( true ); + expect( site._options.endpoint ).toBe( 'endpoint/url/' ); + expect( site._options.transport ).toBeDefined(); + expect( site._options.transport.get ).toBe( transport.get ); + } ); + +} ); diff --git a/tests/unit/lib/util/object-reduce.js b/tests/unit/lib/util/object-reduce.js index 0d26a555..0cde240a 100644 --- a/tests/unit/lib/util/object-reduce.js +++ b/tests/unit/lib/util/object-reduce.js @@ -1,43 +1,28 @@ 'use strict'; -describe( 'Object reduction tools:', () => { - // Ensure parity with the relevant signature & functionality of lodash.reduce - [ - { - name: 'lodash.reduce (for API parity verification)', - fn: require( 'lodash.reduce' ), - }, { - name: 'objectReduce utility', - fn: require( '../../../../lib/util/object-reduce' ), - }, - ].forEach( ( test ) => { +const objectReduce = require( '../../../../lib/util/object-reduce' ); - describe( test.name, () => { - const objectReduce = test.fn; +describe( 'objectReduce utility', () => { - it( 'is defined', () => { - expect( objectReduce ).toBeDefined(); - } ); - - it( 'is a function', () => { - expect( typeof objectReduce ).toBe( 'function' ); - } ); - - it( 'resolves to the provided initial value if called on an empty object', () => { - expect( objectReduce( {}, () => {}, 'Sasquatch' ) ).toBe( 'Sasquatch' ); - } ); + it( 'is defined', () => { + expect( objectReduce ).toBeDefined(); + } ); - it( 'can be used to reduce over an object', () => { - const result = objectReduce( { - key1: 'val1', - key2: 'val2', - key3: 'val3', - }, ( memo, val, key ) => memo + val + key, 'result:' ); - expect( result ).toBe( 'result:val1key1val2key2val3key3' ); - } ); + it( 'is a function', () => { + expect( typeof objectReduce ).toBe( 'function' ); + } ); - } ); + it( 'resolves to the provided initial value if called on an empty object', () => { + expect( objectReduce( {}, () => {}, 'Sasquatch' ) ).toBe( 'Sasquatch' ); + } ); + it( 'can be used to reduce over an object', () => { + const result = objectReduce( { + key1: 'val1', + key2: 'val2', + key3: 'val3', + }, ( memo, val, key ) => memo + val + key, 'result:' ); + expect( result ).toBe( 'result:val1key1val2key2val3key3' ); } ); } ); diff --git a/webpack.config.js b/webpack.config.js index 63bd472d..77f38fe3 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,7 +3,20 @@ const { join } = require( 'path' ); module.exports = { - entry: './wpapi.js', + entry: { + wpapi: './fetch', + 'wpapi-superagent': './superagent', + }, + + // Use browser builtins instead of Node packages where appropriate. + externals: { + 'node-fetch': 'fetch', + 'form-data': 'FormData', + }, + + node: { + fs: 'empty', + }, mode: 'development', @@ -21,7 +34,7 @@ module.exports = { output: { path: join( process.cwd(), 'browser' ), - filename: 'wpapi.js', + filename: '[name].js', library: 'WPAPI', libraryTarget: 'umd', }, @@ -30,7 +43,7 @@ module.exports = { rules: [ { test: /\.js$/, - exclude: /(node_modules|bower_components)/, + exclude: /(node_modules)/, loader: require.resolve( 'babel-loader' ), options: { presets: [ '@babel/preset-env' ], diff --git a/webpack.config.minified.js b/webpack.config.minified.js index 62ce81db..28c3ed3e 100644 --- a/webpack.config.minified.js +++ b/webpack.config.minified.js @@ -14,7 +14,7 @@ module.exports = { output: { ...config.output, - filename: 'wpapi.min.js', + filename: '[name].min.js', }, optimization: { diff --git a/wpapi.js b/wpapi.js index 4fe18f7c..c3e52325 100644 --- a/wpapi.js +++ b/wpapi.js @@ -111,7 +111,7 @@ function WPAPI( options ) { * } * * // Delegate to default transport if no cached data was found - * return WPAPI.transport.get( wpreq ).then(function( result ) { + * return this.constructor.transport.get( wpreq ).then(function( result ) { * cache[ wpreq ] = result; * return result; * }); @@ -138,10 +138,10 @@ WPAPI.prototype.transport = function( transport ) { // Local reference to avoid need to reference via `this` inside forEach const _options = this._options; - // Create the default transport if it does not exist + // Attempt to use the default transport if no override was provided if ( ! _options.transport ) { - _options.transport = WPAPI.transport ? - Object.create( WPAPI.transport ) : + _options.transport = this.constructor.transport ? + Object.create( this.constructor.transport ) : {}; } @@ -155,47 +155,6 @@ WPAPI.prototype.transport = function( transport ) { return this; }; -/** - * Convenience method for making a new WPAPI instance - * - * @example - * These are equivalent: - * - * var wp = new WPAPI({ endpoint: 'http://my.blog.url/wp-json' }); - * var wp = WPAPI.site( 'http://my.blog.url/wp-json' ); - * - * `WPAPI.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 WPAPI({ - * endpoint: 'http://my.blog.url/wp-json', - * json: {...} - * }); - * var wp = WPAPI.site( 'http://my.blog.url/wp-json', {...} ); - * - * @memberof! WPAPI - * @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 - * @returns {WPAPI} A new WPAPI instance, bound to the provided endpoint - */ -WPAPI.site = function( endpoint, routes ) { - return new WPAPI( { - endpoint: endpoint, - routes: routes, - } ); -}; - /** * Generate a request against a completely arbitrary endpoint, with no assumptions about * or mutation of path, filtering, or query parameters. This request is not restricted to @@ -392,28 +351,43 @@ WPAPI.prototype.namespace = function( 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 WPAPI instance bound to the provided site. + * Convenience method for making a new WPAPI instance for a given API root + * + * @example + * These are equivalent: + * + * var wp = new WPAPI({ endpoint: 'http://my.blog.url/wp-json' }); + * var wp = WPAPI.site( 'http://my.blog.url/wp-json' ); + * + * `WPAPI.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 WPAPI({ + * endpoint: 'http://my.blog.url/wp-json', + * json: {...} + * }); + * var wp = WPAPI.site( 'http://my.blog.url/wp-json', {...} ); * * @memberof! WPAPI * @static - * @param {string} url A URL within a REST API-enabled WordPress website - * @returns {Promise} A promise that resolves to a configured WPAPI instance bound - * to the deduced endpoint, or rejected if an endpoint is not found or the - * library is unable to parse the provided endpoint. + * @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 + * @returns {WPAPI} A new WPAPI instance, bound to the provided endpoint */ -WPAPI.discover = ( url ) => { - // Use WPAPI.site to make a request using the defined transport - const req = WPAPI.site( url ).root().param( 'rest_route', '/' ); - return req.get().then( ( apiRootJSON ) => { - const routes = apiRootJSON.routes; - return new WPAPI( { - // Derive the endpoint from the self link for the / root - endpoint: routes['/']._links.self, - // Bootstrap returned WPAPI instance with the discovered routes - routes: routes, - } ); +WPAPI.site = ( endpoint, routes ) => { + return new WPAPI( { + endpoint: endpoint, + routes: routes, } ); };