From f954eedb51925dd9c6158fcca743fd0af4154f86 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Mon, 27 May 2019 14:36:15 -0400 Subject: [PATCH 01/19] Implement rough first-draft axios transport --- axios/README.md | 14 ++ axios/axios-transport.js | 368 ++++++++++++++++++++++++++++++++++++++ axios/index.js | 6 + axios/tests/.eslintrc.js | 5 + axios/tests/unit/axios.js | 164 +++++++++++++++++ package.json | 2 + 6 files changed, 559 insertions(+) create mode 100644 axios/README.md create mode 100644 axios/axios-transport.js create mode 100644 axios/index.js create mode 100644 axios/tests/.eslintrc.js create mode 100644 axios/tests/unit/axios.js diff --git a/axios/README.md b/axios/README.md new file mode 100644 index 00000000..7da40953 --- /dev/null +++ b/axios/README.md @@ -0,0 +1,14 @@ +# `wpapi/superagent` + +This endpoint returns a version of the WPAPI library configured to use Axios for HTTP requests. + +## Installation & Usage + +Install both `wpapi` and `axios` using the command `npm install --save wpapi axios`. + +```js +import WPAPI from 'wpapi/axios'; + +// Configure and use WPAPI as normal +const site = new WPAPI( { /* ... */ } ); +``` diff --git a/axios/axios-transport.js b/axios/axios-transport.js new file mode 100644 index 00000000..1f94fa38 --- /dev/null +++ b/axios/axios-transport.js @@ -0,0 +1,368 @@ +/** + * @module http-transport + */ +'use strict'; + +const axios = require( 'axios' ); +const parseLinkHeader = require( 'li' ).parse; +const FormData = require( 'form-data' ); + +const WPRequest = require( '../lib/constructors/wp-request' ); +const objectReduce = require( '../lib/util/object-reduce' ); +const isEmptyObject = require( '../lib/util/is-empty-object' ); + +/** + * Utility method to set a header value on an axios 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 An axios request configuration object + * @param {Object} options A WPRequest _options object + * @param {Object} An axios 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 An axios request configuration object + * @param {Object} options A WPRequest _options object + * @param {Boolean} forceAuthentication whether to force authentication on the request + * @param {Object} An axios 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 ) { + 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 + return { + ...config, + auth: { + username: options.username, + password: options.password, + }, + }; +} + +// Pagination-Related Helpers +// ========================== + +/** + * Extract the body property from the axios response, or else try to parse + * the response text to get a JSON object. + * + * @private + * @param {Object} response The response object from the HTTP request + * @param {String} response.text The response content as text + * @param {Object} response.body The response content as a JS object + * @returns {Object} The response content as a JS object + */ +function extractResponseBody( response ) { + let responseBody = response.data; + if ( isEmptyObject( responseBody ) && response.type === 'text/html' ) { + // Response may have come back as HTML due to caching plugin; try to parse + // the response text into JSON + try { + responseBody = JSON.parse( response.text ); + } catch ( e ) { + // Swallow errors, it's OK to fall back to returning the body + } + } + 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} response 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( response, options, httpTransport ) { + let _paging = null; + + if ( ! response.headers ) { + // No headers: return as-is + return _paging; + } + + const headers = response.headers; + + // Guard against capitalization inconsistencies in returned headers + Object.keys( headers ).forEach( ( header ) => { + headers[ header.toLowerCase() ] = headers[ header ]; + } ); + + if ( ! headers[ 'x-wp-totalpages' ] ) { + // No paging: return as-is + return _paging; + } + + const totalPages = +headers[ 'x-wp-totalpages' ]; + + if ( ! totalPages || totalPages === 0 ) { + // No paging: return as-is + return _paging; + } + + // Decode the link header object + const links = headers.link ? + parseLinkHeader( headers.link ) : + {}; + + // Store pagination data from response headers on the response collection + _paging = { + total: +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 +// ==================== + +/** + * 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 axios response object for the HTTP call + * @returns {Object} The "body" property of the response, conditionally augmented with + * pagination information if the response is a partial collection. + */ +function returnBody( wpreq, response ) { + // console.log( response ); + console.log( wpreq.toString() ); + console.log( response.headers ); + const body = extractResponseBody( response ); + const _paging = createPaginationObject( response, wpreq._options, wpreq.transport ); + if ( _paging ) { + body._paging = _paging; + } + return body; +} + +/** + * Handle errors received during axios requests. + * + * @param {Object} err Axios error response object. + */ +function handleErrors( err ) { + // Check to see if a request came back at all. + // If the API provided an error object, it will be available within the + // axios response object as .response.data (containing the response + // JSON). If that object exists, it will have a .code property if it is + // truly an API error (non-API errors will not have a .code). + if ( err.response && err.response.data && err.response.data.code ) { + // Forward API error response JSON on to the calling method: omit + // all transport-specific (axios-specific) properties + throw err.response.data; + } + // Re-throw the unmodified error for other issues, to aid debugging. + throw err; +} + +// HTTP Methods: Private HTTP-verb versions +// ======================================== + +/** + * @method get + * @async + * @param {WPRequest} wpreq A WPRequest query object + * @returns {Promise} A promise to the results of the HTTP request + */ +function _httpGet( wpreq ) { + const url = wpreq.toString(); + + const config = _setHeaders( _auth( {}, wpreq._options ), wpreq._options ); + return axios.get( url, { + auth: { + username: 'admin', + password: 'password', + }, + } ) + .then( (r) => { + console.log( r.headers ); + console.log( '<<' ); + return r; + } ) + .then( response => returnBody( wpreq, response ) ) + .catch( handleErrors ); +} + +/** + * 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 = {} ) { + const url = wpreq.toString(); + + let config = _setHeaders( _auth( {}, wpreq._options, true ), wpreq._options ); + + if ( wpreq._attachment ) { + // Data must be form-encoded alongside image attachment + const form = new FormData(); + data = objectReduce( + data, + ( form, value, key ) => form.append( key, value ), + // TODO: Probably need to read in the file if a string path is given + form.append( 'file', wpreq._attachment, wpreq._attachmentName ) + ); + config = objectReduce( + form.getHeaders(), + ( config, value, key ) => _setHeader( config, key, value ), + config + ); + } + + return axios.post( url, data, config ) + .then( response => returnBody( wpreq, response ) ) + .catch( handleErrors ); +} + +/** + * @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 = {} ) { + const url = wpreq.toString(); + + const config = _setHeaders( _auth( {}, wpreq._options, true ), wpreq._options ); + + return axios.put( url, data, config ) + .then( response => returnBody( wpreq, response ) ) + .catch( handleErrors ); +} + +/** + * @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 url = wpreq.toString(); + const config = _setHeaders( _auth( {}, wpreq._options, true ), wpreq._options ); + + // See https://github.com/axios/axios/issues/897#issuecomment-343715381 + if ( data ) { + config.data = data; + } + + return axios.delete( url, config ) + .then( response => returnBody( wpreq, response ) ) + .catch( handleErrors ); +} + +/** + * @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( {}, wpreq._options, true ), wpreq._options ); + + return axios.head( url, config ) + .then( response => response.headers ) + .catch( handleErrors ); +} + +module.exports = { + delete: _httpDelete, + get: _httpGet, + head: _httpHead, + post: _httpPost, + put: _httpPut, +}; diff --git a/axios/index.js b/axios/index.js new file mode 100644 index 00000000..67e6e63a --- /dev/null +++ b/axios/index.js @@ -0,0 +1,6 @@ +const WPAPI = require( '../wpapi' ); +const axiosTransport = require( './axios-transport' ); +const bindTransport = require( '../lib/bind-transport' ); + +// Bind the axios-based HTTP transport to the WPAPI constructor +module.exports = bindTransport( WPAPI, axiosTransport ); diff --git a/axios/tests/.eslintrc.js b/axios/tests/.eslintrc.js new file mode 100644 index 00000000..bd527d76 --- /dev/null +++ b/axios/tests/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + 'env': { + jest: true, + }, +}; diff --git a/axios/tests/unit/axios.js b/axios/tests/unit/axios.js new file mode 100644 index 00000000..51c754e4 --- /dev/null +++ b/axios/tests/unit/axios.js @@ -0,0 +1,164 @@ +'use strict'; + +const WPAPI = require( '../../' ); + +// HTTP transport, for stubbing +const axiosTransport = require( '../../axios-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( axiosTransport, 'get' ).mockImplementation( () => {} ); + const site = new WPAPI( { + endpoint: 'http://some.url.com/wp-json', + } ); + const query = site.root( '' ); + query.get(); + expect( axiosTransport.get ).toHaveBeenCalledWith( query ); + axiosTransport.get.mockRestore(); + } ); + + it( 'for POST requests', () => { + jest.spyOn( axiosTransport, 'post' ).mockImplementation( () => {} ); + const site = new WPAPI( { + endpoint: 'http://some.url.com/wp-json', + } ); + const query = site.root( '' ); + const data = {}; + query.create( data ); + expect( axiosTransport.post ).toHaveBeenCalledWith( query, data ); + axiosTransport.post.mockRestore(); + } ); + + it( 'for POST requests', () => { + jest.spyOn( axiosTransport, 'post' ).mockImplementation( () => {} ); + const site = new WPAPI( { + endpoint: 'http://some.url.com/wp-json', + } ); + const query = site.root( '' ); + const data = {}; + query.create( data ); + expect( axiosTransport.post ).toHaveBeenCalledWith( query, data ); + axiosTransport.post.mockRestore(); + } ); + + it( 'for PUT requests', () => { + jest.spyOn( axiosTransport, '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( axiosTransport.put ).toHaveBeenCalledWith( query, data ); + axiosTransport.put.mockRestore(); + } ); + + it( 'for DELETE requests', () => { + jest.spyOn( axiosTransport, '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( axiosTransport.delete ).toHaveBeenCalledWith( query, data ); + axiosTransport.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( axiosTransport, 'get' ).mockImplementation( () => {} ); + } ); + + afterEach( () => { + axiosTransport.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'; + axiosTransport.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( axiosTransport.get ).toBeCalledTimes( 1 ); + const indexRequestObject = axiosTransport.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'; + axiosTransport.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/package.json b/package.json index 3345d68d..ec732760 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,8 @@ "qs": "^6.6.0" }, "optionalDependencies": { + "axios": "^0.18.0", + "form-data": "^2.3.3", "superagent": "^4.1.0" }, "devDependencies": { From 4a0cd610346e606e6f7feef8b35b13901ff7d65f Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Mon, 27 May 2019 22:01:30 -0400 Subject: [PATCH 02/19] Extract pagination helper into central module This will be used by all transports so there is no reason to duplicate code --- lib/pagination.js | 89 ++++++++++++++++++++++++++++++ superagent/superagent-transport.js | 83 +--------------------------- 2 files changed, 90 insertions(+), 82 deletions(-) create mode 100644 lib/pagination.js diff --git a/lib/pagination.js b/lib/pagination.js new file mode 100644 index 00000000..2936a5fb --- /dev/null +++ b/lib/pagination.js @@ -0,0 +1,89 @@ +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 ) { + console.log( 'NOPE' ); + console.log( 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/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 // ==================== From 36c89fcbb130e04a9e21adc9998726391173a33a Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Mon, 27 May 2019 22:03:51 -0400 Subject: [PATCH 03/19] Remove axios transport in favor of using fetch --- axios/README.md | 14 - axios/axios-transport.js | 368 ------------------ axios/index.js | 6 - fetch/README.md | 14 + fetch/fetch-transport.js | 263 +++++++++++++ fetch/index.js | 6 + {axios => fetch}/tests/.eslintrc.js | 0 .../axios.js => fetch/tests/unit/fetch.js | 46 +-- lib/bind-transport.js | 2 +- lib/pagination.js | 2 - package.json | 2 +- tests/integration/autodiscovery.js | 1 + tests/integration/categories.js | 1 + tests/integration/comments.js | 1 + tests/integration/custom-http-headers.js | 1 + tests/integration/custom-http-transport.js | 2 + tests/integration/error-states.js | 2 + tests/integration/pages.js | 1 + tests/integration/posts.js | 4 +- tests/integration/settings.js | 1 + tests/integration/tags.js | 1 + tests/integration/taxonomies.js | 1 + tests/integration/types.js | 1 + 23 files changed, 324 insertions(+), 416 deletions(-) delete mode 100644 axios/README.md delete mode 100644 axios/axios-transport.js delete mode 100644 axios/index.js create mode 100644 fetch/README.md create mode 100644 fetch/fetch-transport.js create mode 100644 fetch/index.js rename {axios => fetch}/tests/.eslintrc.js (100%) rename axios/tests/unit/axios.js => fetch/tests/unit/fetch.js (73%) diff --git a/axios/README.md b/axios/README.md deleted file mode 100644 index 7da40953..00000000 --- a/axios/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# `wpapi/superagent` - -This endpoint returns a version of the WPAPI library configured to use Axios for HTTP requests. - -## Installation & Usage - -Install both `wpapi` and `axios` using the command `npm install --save wpapi axios`. - -```js -import WPAPI from 'wpapi/axios'; - -// Configure and use WPAPI as normal -const site = new WPAPI( { /* ... */ } ); -``` diff --git a/axios/axios-transport.js b/axios/axios-transport.js deleted file mode 100644 index 1f94fa38..00000000 --- a/axios/axios-transport.js +++ /dev/null @@ -1,368 +0,0 @@ -/** - * @module http-transport - */ -'use strict'; - -const axios = require( 'axios' ); -const parseLinkHeader = require( 'li' ).parse; -const FormData = require( 'form-data' ); - -const WPRequest = require( '../lib/constructors/wp-request' ); -const objectReduce = require( '../lib/util/object-reduce' ); -const isEmptyObject = require( '../lib/util/is-empty-object' ); - -/** - * Utility method to set a header value on an axios 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 An axios request configuration object - * @param {Object} options A WPRequest _options object - * @param {Object} An axios 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 An axios request configuration object - * @param {Object} options A WPRequest _options object - * @param {Boolean} forceAuthentication whether to force authentication on the request - * @param {Object} An axios 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 ) { - 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 - return { - ...config, - auth: { - username: options.username, - password: options.password, - }, - }; -} - -// Pagination-Related Helpers -// ========================== - -/** - * Extract the body property from the axios response, or else try to parse - * the response text to get a JSON object. - * - * @private - * @param {Object} response The response object from the HTTP request - * @param {String} response.text The response content as text - * @param {Object} response.body The response content as a JS object - * @returns {Object} The response content as a JS object - */ -function extractResponseBody( response ) { - let responseBody = response.data; - if ( isEmptyObject( responseBody ) && response.type === 'text/html' ) { - // Response may have come back as HTML due to caching plugin; try to parse - // the response text into JSON - try { - responseBody = JSON.parse( response.text ); - } catch ( e ) { - // Swallow errors, it's OK to fall back to returning the body - } - } - 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} response 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( response, options, httpTransport ) { - let _paging = null; - - if ( ! response.headers ) { - // No headers: return as-is - return _paging; - } - - const headers = response.headers; - - // Guard against capitalization inconsistencies in returned headers - Object.keys( headers ).forEach( ( header ) => { - headers[ header.toLowerCase() ] = headers[ header ]; - } ); - - if ( ! headers[ 'x-wp-totalpages' ] ) { - // No paging: return as-is - return _paging; - } - - const totalPages = +headers[ 'x-wp-totalpages' ]; - - if ( ! totalPages || totalPages === 0 ) { - // No paging: return as-is - return _paging; - } - - // Decode the link header object - const links = headers.link ? - parseLinkHeader( headers.link ) : - {}; - - // Store pagination data from response headers on the response collection - _paging = { - total: +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 -// ==================== - -/** - * 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 axios response object for the HTTP call - * @returns {Object} The "body" property of the response, conditionally augmented with - * pagination information if the response is a partial collection. - */ -function returnBody( wpreq, response ) { - // console.log( response ); - console.log( wpreq.toString() ); - console.log( response.headers ); - const body = extractResponseBody( response ); - const _paging = createPaginationObject( response, wpreq._options, wpreq.transport ); - if ( _paging ) { - body._paging = _paging; - } - return body; -} - -/** - * Handle errors received during axios requests. - * - * @param {Object} err Axios error response object. - */ -function handleErrors( err ) { - // Check to see if a request came back at all. - // If the API provided an error object, it will be available within the - // axios response object as .response.data (containing the response - // JSON). If that object exists, it will have a .code property if it is - // truly an API error (non-API errors will not have a .code). - if ( err.response && err.response.data && err.response.data.code ) { - // Forward API error response JSON on to the calling method: omit - // all transport-specific (axios-specific) properties - throw err.response.data; - } - // Re-throw the unmodified error for other issues, to aid debugging. - throw err; -} - -// HTTP Methods: Private HTTP-verb versions -// ======================================== - -/** - * @method get - * @async - * @param {WPRequest} wpreq A WPRequest query object - * @returns {Promise} A promise to the results of the HTTP request - */ -function _httpGet( wpreq ) { - const url = wpreq.toString(); - - const config = _setHeaders( _auth( {}, wpreq._options ), wpreq._options ); - return axios.get( url, { - auth: { - username: 'admin', - password: 'password', - }, - } ) - .then( (r) => { - console.log( r.headers ); - console.log( '<<' ); - return r; - } ) - .then( response => returnBody( wpreq, response ) ) - .catch( handleErrors ); -} - -/** - * 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 = {} ) { - const url = wpreq.toString(); - - let config = _setHeaders( _auth( {}, wpreq._options, true ), wpreq._options ); - - if ( wpreq._attachment ) { - // Data must be form-encoded alongside image attachment - const form = new FormData(); - data = objectReduce( - data, - ( form, value, key ) => form.append( key, value ), - // TODO: Probably need to read in the file if a string path is given - form.append( 'file', wpreq._attachment, wpreq._attachmentName ) - ); - config = objectReduce( - form.getHeaders(), - ( config, value, key ) => _setHeader( config, key, value ), - config - ); - } - - return axios.post( url, data, config ) - .then( response => returnBody( wpreq, response ) ) - .catch( handleErrors ); -} - -/** - * @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 = {} ) { - const url = wpreq.toString(); - - const config = _setHeaders( _auth( {}, wpreq._options, true ), wpreq._options ); - - return axios.put( url, data, config ) - .then( response => returnBody( wpreq, response ) ) - .catch( handleErrors ); -} - -/** - * @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 url = wpreq.toString(); - const config = _setHeaders( _auth( {}, wpreq._options, true ), wpreq._options ); - - // See https://github.com/axios/axios/issues/897#issuecomment-343715381 - if ( data ) { - config.data = data; - } - - return axios.delete( url, config ) - .then( response => returnBody( wpreq, response ) ) - .catch( handleErrors ); -} - -/** - * @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( {}, wpreq._options, true ), wpreq._options ); - - return axios.head( url, config ) - .then( response => response.headers ) - .catch( handleErrors ); -} - -module.exports = { - delete: _httpDelete, - get: _httpGet, - head: _httpHead, - post: _httpPost, - put: _httpPut, -}; diff --git a/axios/index.js b/axios/index.js deleted file mode 100644 index 67e6e63a..00000000 --- a/axios/index.js +++ /dev/null @@ -1,6 +0,0 @@ -const WPAPI = require( '../wpapi' ); -const axiosTransport = require( './axios-transport' ); -const bindTransport = require( '../lib/bind-transport' ); - -// Bind the axios-based HTTP transport to the WPAPI constructor -module.exports = bindTransport( WPAPI, axiosTransport ); 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..4b31e21d --- /dev/null +++ b/fetch/fetch-transport.js @@ -0,0 +1,263 @@ +/** + * @module fetch-transport + */ +'use strict'; + +const fetch = require( 'isomorphic-unfetch' ); +// const FormData = require( 'form-data' ); + +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; + } ); + } + + // 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 = {} ) { + // if ( wpreq._attachment ) { + // // Data must be form-encoded alongside image attachment + // const form = new FormData(); + // data = objectReduce( + // data, + // ( form, value, key ) => form.append( key, value ), + // // TODO: Probably need to read in the file if a string path is given + // form.append( 'file', wpreq._attachment, wpreq._attachmentName ) + // ); + // config = objectReduce( + // form.getHeaders(), + // ( config, value, key ) => _setHeader( config, key, value ), + // config + // ); + // } + + 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/axios/tests/.eslintrc.js b/fetch/tests/.eslintrc.js similarity index 100% rename from axios/tests/.eslintrc.js rename to fetch/tests/.eslintrc.js diff --git a/axios/tests/unit/axios.js b/fetch/tests/unit/fetch.js similarity index 73% rename from axios/tests/unit/axios.js rename to fetch/tests/unit/fetch.js index 51c754e4..ba37ae8e 100644 --- a/axios/tests/unit/axios.js +++ b/fetch/tests/unit/fetch.js @@ -1,9 +1,9 @@ 'use strict'; -const WPAPI = require( '../../' ); +const WPAPI = require( '../..' ); // HTTP transport, for stubbing -const axiosTransport = require( '../../axios-transport' ); +const fetchTransport = require( '../../fetch-transport' ); // Variable to use as our "success token" in promise assertions const SUCCESS = 'success'; @@ -15,54 +15,54 @@ describe( 'WPAPI', () => { describe( 'assigns default HTTP transport', () => { it( 'for GET requests', () => { - jest.spyOn( axiosTransport, 'get' ).mockImplementation( () => {} ); + jest.spyOn( fetchTransport, 'get' ).mockImplementation( () => {} ); const site = new WPAPI( { endpoint: 'http://some.url.com/wp-json', } ); const query = site.root( '' ); query.get(); - expect( axiosTransport.get ).toHaveBeenCalledWith( query ); - axiosTransport.get.mockRestore(); + expect( fetchTransport.get ).toHaveBeenCalledWith( query ); + fetchTransport.get.mockRestore(); } ); it( 'for POST requests', () => { - jest.spyOn( axiosTransport, 'post' ).mockImplementation( () => {} ); + 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( axiosTransport.post ).toHaveBeenCalledWith( query, data ); - axiosTransport.post.mockRestore(); + expect( fetchTransport.post ).toHaveBeenCalledWith( query, data ); + fetchTransport.post.mockRestore(); } ); it( 'for POST requests', () => { - jest.spyOn( axiosTransport, 'post' ).mockImplementation( () => {} ); + 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( axiosTransport.post ).toHaveBeenCalledWith( query, data ); - axiosTransport.post.mockRestore(); + expect( fetchTransport.post ).toHaveBeenCalledWith( query, data ); + fetchTransport.post.mockRestore(); } ); it( 'for PUT requests', () => { - jest.spyOn( axiosTransport, 'put' ).mockImplementation( () => {} ); + 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( axiosTransport.put ).toHaveBeenCalledWith( query, data ); - axiosTransport.put.mockRestore(); + expect( fetchTransport.put ).toHaveBeenCalledWith( query, data ); + fetchTransport.put.mockRestore(); } ); it( 'for DELETE requests', () => { - jest.spyOn( axiosTransport, 'delete' ).mockImplementation( () => {} ); + jest.spyOn( fetchTransport, 'delete' ).mockImplementation( () => {} ); const site = new WPAPI( { endpoint: 'http://some.url.com/wp-json', } ); @@ -71,8 +71,8 @@ describe( 'WPAPI', () => { force: true, }; query.delete( data ); - expect( axiosTransport.delete ).toHaveBeenCalledWith( query, data ); - axiosTransport.delete.mockRestore(); + expect( fetchTransport.delete ).toHaveBeenCalledWith( query, data ); + fetchTransport.delete.mockRestore(); } ); } ); @@ -108,11 +108,11 @@ describe( 'WPAPI', () => { describe( '.discover() constructor method', () => { beforeEach( () => { - jest.spyOn( axiosTransport, 'get' ).mockImplementation( () => {} ); + jest.spyOn( fetchTransport, 'get' ).mockImplementation( () => {} ); } ); afterEach( () => { - axiosTransport.get.mockRestore(); + fetchTransport.get.mockRestore(); } ); it( 'is a function', () => { @@ -122,7 +122,7 @@ describe( 'WPAPI', () => { it( 'discovers the API root with a GET request', () => { const url = 'http://mozarts.house'; - axiosTransport.get.mockImplementation( () => Promise.resolve( { + fetchTransport.get.mockImplementation( () => Promise.resolve( { name: 'Skip Beats', descrition: 'Just another WordPress weblog', routes: { @@ -140,8 +140,8 @@ describe( 'WPAPI', () => { .then( ( result ) => { expect( result ).toBeInstanceOf( WPAPI ); expect( result.root().toString() ).toBe( 'http://mozarts.house/wp-json/' ); - expect( axiosTransport.get ).toBeCalledTimes( 1 ); - const indexRequestObject = axiosTransport.get.mock.calls[0][0]; + 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; } ); @@ -150,7 +150,7 @@ describe( 'WPAPI', () => { it( 'throws an error if no API endpoint can be discovered', () => { const url = 'http://we.made.it/to/mozarts/house'; - axiosTransport.get.mockImplementationOnce( () => Promise.reject( 'Some error' ) ); + fetchTransport.get.mockImplementationOnce( () => Promise.reject( 'Some error' ) ); const prom = WPAPI.discover( url ) .catch( ( err ) => { expect( err ).toBe( 'Some error' ); diff --git a/lib/bind-transport.js b/lib/bind-transport.js index 2016d239..e19a614b 100644 --- a/lib/bind-transport.js +++ b/lib/bind-transport.js @@ -1,7 +1,7 @@ /** * Utility method for binding a frozen transport object to the WPAPI constructor * - * See /axios and /superagent directories + * See /fetch and /superagent directories * @param {Function} WPAPI The WPAPI constructor * @param {Object} httpTransport The HTTP transport object * @returns {Function} The WPAPI object augmented with the provided transport diff --git a/lib/pagination.js b/lib/pagination.js index 2936a5fb..6d1a5db1 100644 --- a/lib/pagination.js +++ b/lib/pagination.js @@ -28,8 +28,6 @@ function createPaginationObject( result, options, httpTransport ) { let _paging = null; if ( ! result.headers ) { - console.log( 'NOPE' ); - console.log( result.headers ); // No headers: return as-is return _paging; } diff --git a/package.json b/package.json index ec732760..46182466 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,8 @@ "qs": "^6.6.0" }, "optionalDependencies": { - "axios": "^0.18.0", "form-data": "^2.3.3", + "isomorphic-unfetch": "^3.0.0", "superagent": "^4.1.0" }, "devDependencies": { 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..e161d363 100644 --- a/tests/integration/custom-http-transport.js +++ b/tests/integration/custom-http-transport.js @@ -7,6 +7,8 @@ const SUCCESS = 'success'; describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ), require( '../../superagent/superagent-transport' ) ], + // TODO: Identify why the caching get method is not called with this 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..7371dbe1 100644 --- a/tests/integration/error-states.js +++ b/tests/integration/error-states.js @@ -5,6 +5,8 @@ const SUCCESS = 'success'; describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], + // TODO: Reinstate once invalid route handling is supported properly in fetch transport + // [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: error states:', ( transportName, WPAPI ) => { it( 'invalid root endpoint causes a transport-level (superagent) 404 error', () => { 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..fcc53637 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; @@ -669,7 +670,8 @@ describe.each( [ return expect( prom ).resolves.toBe( SUCCESS ); }, 10000 ); - it( 'can create a post with tags, categories and featured media', () => { + // TODO: Un-skip once image uploading is reinstated. + it.skip( 'can create a post with tags, categories and featured media', () => { let id; let mediaId; const filePath = path.join( __dirname, 'assets/emilygarfield-untitled.jpg' ); 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; From e4219e845f7b8d0eb54e167731666ed1d84a39f1 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 5 Jul 2019 15:08:43 -0400 Subject: [PATCH 04/19] Use FormData to support media uploads in fetch transport --- fetch/fetch-transport.js | 37 +++++++++++++++++++++---------------- package.json | 4 ++-- tests/integration/media.js | 1 + tests/integration/posts.js | 3 +-- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/fetch/fetch-transport.js b/fetch/fetch-transport.js index 4b31e21d..58d2689e 100644 --- a/fetch/fetch-transport.js +++ b/fetch/fetch-transport.js @@ -4,7 +4,8 @@ 'use strict'; const fetch = require( 'isomorphic-unfetch' ); -// const FormData = require( 'form-data' ); +const FormData = require( 'form-data' ); +const fs = require( 'fs' ); const objectReduce = require( '../lib/util/object-reduce' ); const { createPaginationObject } = require( '../lib/pagination' ); @@ -171,21 +172,25 @@ function _httpGet( wpreq ) { * @returns {Promise} A promise to the results of the HTTP request */ function _httpPost( wpreq, data = {} ) { - // if ( wpreq._attachment ) { - // // Data must be form-encoded alongside image attachment - // const form = new FormData(); - // data = objectReduce( - // data, - // ( form, value, key ) => form.append( key, value ), - // // TODO: Probably need to read in the file if a string path is given - // form.append( 'file', wpreq._attachment, wpreq._attachmentName ) - // ); - // config = objectReduce( - // form.getHeaders(), - // ( config, value, key ) => _setHeader( config, key, value ), - // config - // ); - // } + 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', diff --git a/package.json b/package.json index 46182466..39a14017 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", @@ -65,7 +65,7 @@ "qs": "^6.6.0" }, "optionalDependencies": { - "form-data": "^2.3.3", + "form-data": "^2.5.0", "isomorphic-unfetch": "^3.0.0", "superagent": "^4.1.0" }, 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/posts.js b/tests/integration/posts.js index fcc53637..446f10ef 100644 --- a/tests/integration/posts.js +++ b/tests/integration/posts.js @@ -670,8 +670,7 @@ describe.each( [ return expect( prom ).resolves.toBe( SUCCESS ); }, 10000 ); - // TODO: Un-skip once image uploading is reinstated. - it.skip( 'can create a post with tags, categories and featured media', () => { + it( 'can create a post with tags, categories and featured media', () => { let id; let mediaId; const filePath = path.join( __dirname, 'assets/emilygarfield-untitled.jpg' ); From a71f59f35e3f7802bace92dc0191b1229e5b2885 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 5 Jul 2019 18:09:56 -0400 Subject: [PATCH 05/19] Turn the development repository into a WordPress plugin This will simplify iteration & testing of the updated browser builds --- node-wpapi.php | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 node-wpapi.php 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 ); +} From ea38c393b2f32fd6fb6feb3cbb57b5505181d6ec Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 5 Jul 2019 18:27:54 -0400 Subject: [PATCH 06/19] Use latest webpack --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 39a14017..81221ab2 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,8 @@ "minimist": "^1.2.0", "prompt": "^1.0.0", "rimraf": "^2.6.1", - "webpack": "^4.28.1", + "webpack": "^4.35.0", "webpack-bundle-analyzer": "^3.0.3", - "webpack-cli": "^3.1.2" + "webpack-cli": "^3.3.5" } } From e50868ef695e16c9aec2ad1b62f077ee50f3481c Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 5 Jul 2019 18:39:43 -0400 Subject: [PATCH 07/19] Upgrade babel dependencies --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 81221ab2..cddf63b6 100644 --- a/package.json +++ b/package.json @@ -70,9 +70,9 @@ "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/preset-env": "^7.5.0", "babel-loader": "^8.0.4", "combyne": "^2.0.0", "eslint": "^4.19.1", From 347e4eebe940089caceeddf56ea89427969fc35d Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 5 Jul 2019 19:05:41 -0400 Subject: [PATCH 08/19] Remove unused istanbul and lodash.reduce dependencies --- package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/package.json b/package.json index cddf63b6..ddf83be3 100644 --- a/package.json +++ b/package.json @@ -80,12 +80,10 @@ "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", "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", From c1ccaf7529e08e6fc4627a1b7dee29ec88cac682 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 5 Jul 2019 19:10:15 -0400 Subject: [PATCH 09/19] Upgrade dev dependencies --- package.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index ddf83be3..fff225d1 100644 --- a/package.json +++ b/package.json @@ -73,23 +73,23 @@ "@babel/core": "^7.5.0", "@babel/plugin-proposal-object-rest-spread": "^7.2.0", "@babel/preset-env": "^7.5.0", - "babel-loader": "^8.0.4", + "babel-loader": "^8.0.6", "combyne": "^2.0.0", - "eslint": "^4.19.1", - "grunt": "^1.0.1", + "eslint": "^6.0.1", + "grunt": "^1.0.4", "grunt-cli": "^1.2.0", "grunt-contrib-clean": "^1.0.0", "grunt-zip": "^0.17.1", - "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", "minami": "^1.2.3", "minimist": "^1.2.0", "prompt": "^1.0.0", - "rimraf": "^2.6.1", + "rimraf": "^2.6.3", "webpack": "^4.35.0", - "webpack-bundle-analyzer": "^3.0.3", + "webpack-bundle-analyzer": "^3.3.2", "webpack-cli": "^3.3.5" } } From 9496890b602c75691010ec7da424713f95b03661 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 5 Jul 2019 19:12:37 -0400 Subject: [PATCH 10/19] Upgrade qs dependency --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fff225d1..3eb8b478 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "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", From 5f00df324524f587183a786985cb3526954a950e Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 5 Jul 2019 19:22:53 -0400 Subject: [PATCH 11/19] Rename bin/jekyll to bin/jekyll.js; we only use it via node --- bin/{jekyll => jekyll.js} | 4 ---- 1 file changed, 4 deletions(-) rename bin/{jekyll => jekyll.js} (82%) diff --git a/bin/jekyll b/bin/jekyll.js similarity index 82% rename from bin/jekyll rename to bin/jekyll.js index 1b0a8d32..04b92674 100755 --- a/bin/jekyll +++ b/bin/jekyll.js @@ -1,7 +1,3 @@ -#!/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 From 1fa596d51e83674bacb9081cd5226bbdbbfec334 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 5 Jul 2019 19:26:38 -0400 Subject: [PATCH 12/19] Downgrade ESLint back to latest 4.x to fix errors introduced on upgrade --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3eb8b478..ce401c09 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@babel/preset-env": "^7.5.0", "babel-loader": "^8.0.6", "combyne": "^2.0.0", - "eslint": "^6.0.1", + "eslint": "^4.19.1", "grunt": "^1.0.4", "grunt-cli": "^1.2.0", "grunt-contrib-clean": "^1.0.0", From 8e7625aa5c0e7ce452d06e23659ec2bd46ed2697 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 5 Jul 2019 19:26:53 -0400 Subject: [PATCH 13/19] Bring jekyll.js script in line with coding standards --- bin/jekyll.js | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/bin/jekyll.js b/bin/jekyll.js index 04b92674..4b94ed08 100755 --- a/bin/jekyll.js +++ b/bin/jekyll.js @@ -3,6 +3,7 @@ * 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' ); @@ -13,24 +14,24 @@ const argv = require( 'minimist' )( process.argv.slice( 2 ) ); 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' - }); + // Install the ruby bundle needed to run jekyll + const server = spawn( 'bundle', [ 'install' ], { + cwd: docsDir, + stdio: 'inherit', + } ); - server.on( 'error', err => console.error( err ) ); + 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 ); - } + // 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' - }); + const server = spawn( 'bundle', bundleOptions, { + cwd: docsDir, + stdio: 'inherit', + } ); - server.on( 'error', err => console.error( err ) ); + server.on( 'error', err => console.error( err ) ); } From cbc0c6149c353c254ed1284634365e8e39992571 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 5 Jul 2019 19:42:10 -0400 Subject: [PATCH 14/19] Implement frontend build for superagent and fetch (now default) versions --- webpack.config.js | 19 ++++++++++++++++--- webpack.config.minified.js | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/webpack.config.js b/webpack.config.js index 63bd472d..dab02853 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: { + 'isomorphic-unfetch': '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: { From 21bbbef59a221996dcc2156c2b99508a06a4447f Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 5 Jul 2019 19:51:40 -0400 Subject: [PATCH 15/19] Use node-fetch directly in lieu of isomorphic-unfetch isomorphic-unfetch is just a wrapper for node-fetch in Node, and in the browser we leave polyfilling as an exercise for the viewer. No need to ship the intermediary if we are not using its isomorphism. --- fetch/fetch-transport.js | 2 +- package.json | 2 +- webpack.config.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/fetch/fetch-transport.js b/fetch/fetch-transport.js index 58d2689e..f794827c 100644 --- a/fetch/fetch-transport.js +++ b/fetch/fetch-transport.js @@ -3,7 +3,7 @@ */ 'use strict'; -const fetch = require( 'isomorphic-unfetch' ); +const fetch = require( 'node-fetch' ); const FormData = require( 'form-data' ); const fs = require( 'fs' ); diff --git a/package.json b/package.json index ce401c09..4df37586 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ }, "optionalDependencies": { "form-data": "^2.5.0", - "isomorphic-unfetch": "^3.0.0", + "node-fetch": "^2.6.0", "superagent": "^4.1.0" }, "devDependencies": { diff --git a/webpack.config.js b/webpack.config.js index dab02853..77f38fe3 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -10,7 +10,7 @@ module.exports = { // Use browser builtins instead of Node packages where appropriate. externals: { - 'isomorphic-unfetch': 'fetch', + 'node-fetch': 'fetch', 'form-data': 'FormData', }, From 7b3f05dfe8137b2d7fcad89314da0a98e0489276 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Thu, 17 Oct 2019 15:57:57 -0400 Subject: [PATCH 16/19] Remove unnecessary test comparing method to removed lodash.reduce module Cleans up tests following removal of that library in 347e4ee --- lib/util/object-reduce.js | 2 +- tests/unit/lib/util/object-reduce.js | 51 ++++++++++------------------ 2 files changed, 19 insertions(+), 34 deletions(-) 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/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' ); } ); } ); From 8601c07d0685959f385c2aa167130763c18555f9 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 18 Oct 2019 15:10:43 -0400 Subject: [PATCH 17/19] Refactor transport binding to prevent cross-contamination Because of how transports were previously all bound using the single shared WPAPI constructor, all tests which purported to set the transport only for one transport-specific constructor in fact all shared whichever transport was set last. To work around this, the bindTransport method has been enhanced to subclass WPAPI, and the definition of the discover method has therefore moved into that function, rather than the base wpapi.js .site() is useful regardless of whether a transport is present, so it remains in the base class; however, we override .site() with a version which provides instances of the new transport-specific subclasses when creating a new version of the constructor in bindTransport(). As a plus, this fixes the issues we had in the transport binding tests! Turns out that wasn't a false negative after all. --- lib/bind-transport.js | 53 ++++++++++- tests/integration/custom-http-transport.js | 3 +- tests/integration/error-states.js | 5 +- tests/unit/lib/bind-transport.js | 35 ++++++++ wpapi.js | 100 ++++++++------------- 5 files changed, 124 insertions(+), 72 deletions(-) create mode 100644 tests/unit/lib/bind-transport.js diff --git a/lib/bind-transport.js b/lib/bind-transport.js index e19a614b..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 + * + * 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} WPAPI The WPAPI constructor + * + * @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/tests/integration/custom-http-transport.js b/tests/integration/custom-http-transport.js index e161d363..876c248f 100644 --- a/tests/integration/custom-http-transport.js +++ b/tests/integration/custom-http-transport.js @@ -7,8 +7,7 @@ const SUCCESS = 'success'; describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ), require( '../../superagent/superagent-transport' ) ], - // TODO: Identify why the caching get method is not called with this transport - // [ 'wpapi/fetch', require( '../../fetch' ), require( '../../fetch/fetch-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 7371dbe1..eb9f9837 100644 --- a/tests/integration/error-states.js +++ b/tests/integration/error-states.js @@ -5,16 +5,15 @@ const SUCCESS = 'success'; describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], - // TODO: Reinstate once invalid route handling is supported properly in fetch transport // [ '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/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/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, } ); }; From 6cae2fa0321f9b839aee1f67d99edad139623b78 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 18 Oct 2019 15:18:14 -0400 Subject: [PATCH 18/19] Resolve missing trap for failed deserialization in fetch transport --- fetch/fetch-transport.js | 3 +++ tests/integration/error-states.js | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/fetch/fetch-transport.js b/fetch/fetch-transport.js index f794827c..023f1af1 100644 --- a/fetch/fetch-transport.js +++ b/fetch/fetch-transport.js @@ -122,6 +122,9 @@ const parseFetchResponse = ( response, wpreq ) => { 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; } ); } diff --git a/tests/integration/error-states.js b/tests/integration/error-states.js index eb9f9837..31b7abf7 100644 --- a/tests/integration/error-states.js +++ b/tests/integration/error-states.js @@ -5,7 +5,7 @@ const SUCCESS = 'success'; describe.each( [ [ 'wpapi/superagent', require( '../../superagent' ) ], - // [ 'wpapi/fetch', require( '../../fetch' ) ], + [ 'wpapi/fetch', require( '../../fetch' ) ], ] )( '%s: error states:', ( transportName, WPAPI ) => { it( 'invalid root endpoint causes a transport-level 404 error', () => { From 2f06556a4cd21c157761f0784c31401ba9a61576 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Fri, 18 Oct 2019 15:21:44 -0400 Subject: [PATCH 19/19] Reinstate assertion that now works once more ...What was PPP again? --- tests/integration/posts.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/integration/posts.js b/tests/integration/posts.js index 446f10ef..42a91784 100644 --- a/tests/integration/posts.js +++ b/tests/integration/posts.js @@ -177,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 ); @@ -221,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 ) => {