Merge pull request #28186 from woocommerce/packages/api/fix/pretty-permalinks-and-http-status

@woocommerce/api: Better support pretty permalinks
This commit is contained in:
Christopher Allford 2020-11-04 10:52:59 -08:00 committed by GitHub
commit 5f7454ae18
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 260 additions and 66 deletions

View File

@ -24,24 +24,14 @@ The simplest way to use the client is directly:
import { HTTPClientFactory } from '@woocommerce/api'; import { HTTPClientFactory } from '@woocommerce/api';
// You can create an API client using the client factory with pre-configured middleware for convenience. // You can create an API client using the client factory with pre-configured middleware for convenience.
let httpClient = HTTPClientFactory.withBasicAuth( let client = HTTPClientFactory.build( 'https://example.com' )
// The base URL of your REST API. .withBasicAuth( 'username', 'password' )
'https://example.com/wp-json/', .create();
// The username for your WordPress user.
'username',
// The password for your WordPress user.
'password',
);
// You can also create an API client configured for requests using OAuth. // You can also create an API client configured for requests using OAuth.
httpClient = HTTPClientFactory.withOAuth( client = HTTPClientFactory.build( 'https://example.com' )
// The base URL of your REST API. .withOAuth( 'consumer_secret', 'consumer_password' )
'https://example.com/wp-json/', .create();
// The OAuth API Key's consumer secret.
'consumer_secret',
// The OAuth API Key's consumer password.
'consumer_pasword',
);
// You can then use the client to make API requests. // You can then use the client to make API requests.
httpClient.get( '/wc/v3/products' ).then( ( response ) => { httpClient.get( '/wc/v3/products' ).then( ( response ) => {
@ -54,6 +44,7 @@ httpClient.get( '/wc/v3/products' ).then( ( response ) => {
}, ( error ) => { }, ( error ) => {
// Handle errors that may have come up. // Handle errors that may have come up.
} ); } );
``` ```
### Repositories ### Repositories
@ -66,7 +57,9 @@ import { SimpleProduct } from '@woocommerce/api';
// Prepare the HTTP client that will be consumed by the repository. // Prepare the HTTP client that will be consumed by the repository.
// This is necessary so that it can make requests to the REST API. // This is necessary so that it can make requests to the REST API.
const httpClient = HTTPClientFactory.withBasicAuth( 'https://example.com/wp-json/','username','password' ); const httpClient = HTTPClientFactory.build( 'https://example.com' )
.withBasicAuth( 'username', 'password' )
.create();
const repository = SimpleProduct.restRepository( httpClient ); const repository = SimpleProduct.restRepository( httpClient );

View File

@ -0,0 +1,34 @@
import axios, { AxiosInstance } from 'axios';
import * as moxios from 'moxios';
import { AxiosURLToQueryInterceptor } from '../axios-url-to-query-interceptor';
describe( 'AxiosURLToQueryInterceptor', () => {
let urlToQueryInterceptor: AxiosURLToQueryInterceptor;
let axiosInstance: AxiosInstance;
beforeEach( () => {
axiosInstance = axios.create();
moxios.install( axiosInstance );
urlToQueryInterceptor = new AxiosURLToQueryInterceptor( 'test' );
urlToQueryInterceptor.start( axiosInstance );
} );
afterEach( () => {
urlToQueryInterceptor.stop( axiosInstance );
moxios.uninstall();
} );
it( 'should put path in query string', async () => {
moxios.stubRequest( 'http://test.test/?test=%2Ftest%2Froute', {
status: 200,
headers: {
'Content-Type': 'application/json',
},
responseText: JSON.stringify( { test: 'value' } ),
} );
const response = await axiosInstance.get( 'http://test.test/test/route' );
expect( response.status ).toEqual( 200 );
} );
} );

View File

@ -1,4 +1,4 @@
import { buildURL } from '../utils'; import { buildURL, buildURLWithParams } from '../utils';
describe( 'buildURL', () => { describe( 'buildURL', () => {
it( 'should use base when given no url', () => { it( 'should use base when given no url', () => {
@ -15,9 +15,16 @@ describe( 'buildURL', () => {
const url = buildURL( { baseURL: 'http://test.test', url: 'yes/test' } ); const url = buildURL( { baseURL: 'http://test.test', url: 'yes/test' } );
expect( url ).toBe( 'http://test.test/yes/test' ); expect( url ).toBe( 'http://test.test/yes/test' );
} ); } );
} );
it( 'should combine base and url with trailing/leading slashes', () => { describe( 'buildURLWithParams', () => {
const url = buildURL( { baseURL: 'http://test.test/////', url: '////yes/test' } ); it( 'should do nothing without query string', () => {
expect( url ).toBe( 'http://test.test/yes/test' ); const url = buildURLWithParams( { baseURL: 'http://test.test' } );
expect( url ).toBe( 'http://test.test' );
} );
it( 'should append query string', () => {
const url = buildURLWithParams( { baseURL: 'http://test.test', params: { test: 'yes' } } );
expect( url ).toBe( 'http://test.test?test=yes' );
} ); } );
} ); } );

View File

@ -2,10 +2,10 @@ import type { AxiosRequestConfig } from 'axios';
import * as createHmac from 'create-hmac'; import * as createHmac from 'create-hmac';
import * as OAuth from 'oauth-1.0a'; import * as OAuth from 'oauth-1.0a';
import { AxiosInterceptor } from './axios-interceptor'; import { AxiosInterceptor } from './axios-interceptor';
import { buildURL } from './utils'; import { buildURLWithParams } from './utils';
/** /**
* A utility class for managing the lifecycle of an authentication interceptor. * An interceptor for adding OAuth 1.0a signatures to HTTP requests.
*/ */
export class AxiosOAuthInterceptor extends AxiosInterceptor { export class AxiosOAuthInterceptor extends AxiosInterceptor {
/** /**
@ -14,7 +14,7 @@ export class AxiosOAuthInterceptor extends AxiosInterceptor {
* @type {Object} * @type {Object}
* @private * @private
*/ */
private oauth: OAuth; private readonly oauth: OAuth;
/** /**
* Creates a new interceptor. * Creates a new interceptor.
@ -44,7 +44,7 @@ export class AxiosOAuthInterceptor extends AxiosInterceptor {
* @return {AxiosRequestConfig} The request with the additional authorization headers. * @return {AxiosRequestConfig} The request with the additional authorization headers.
*/ */
protected handleRequest( request: AxiosRequestConfig ): AxiosRequestConfig { protected handleRequest( request: AxiosRequestConfig ): AxiosRequestConfig {
const url = buildURL( request ); const url = buildURLWithParams( request );
if ( url.startsWith( 'https' ) ) { if ( url.startsWith( 'https' ) ) {
request.auth = { request.auth = {
username: this.oauth.consumer.key, username: this.oauth.consumer.key,

View File

@ -2,6 +2,9 @@ import { AxiosResponse } from 'axios';
import { AxiosInterceptor } from './axios-interceptor'; import { AxiosInterceptor } from './axios-interceptor';
import { HTTPResponse } from '../http-client'; import { HTTPResponse } from '../http-client';
/**
* An interceptor for transforming the responses from axios into a consistent format for package consumers.
*/
export class AxiosResponseInterceptor extends AxiosInterceptor { export class AxiosResponseInterceptor extends AxiosInterceptor {
/** /**
* Transforms the Axios response into our HTTP response. * Transforms the Axios response into our HTTP response.

View File

@ -0,0 +1,53 @@
import { AxiosInterceptor } from './axios-interceptor';
import { AxiosRequestConfig } from 'axios';
import { buildURL } from './utils';
/**
* An interceptor for transforming the request's path into a query parameter.
*/
export class AxiosURLToQueryInterceptor extends AxiosInterceptor {
/**
* The query parameter we want to assign the path to.
*
* @type {string}
* @private
*/
private readonly queryParam: string;
/**
* Constructs a new interceptor.
*
* @param {string} queryParam The query parameter we want to assign the path to.
*/
public constructor( queryParam: string ) {
super();
this.queryParam = queryParam;
}
/**
* Converts the outgoing path into a query parameter.
*
* @param {AxiosRequestConfig} config The axios config.
* @return {AxiosRequestConfig} The axios config.
*/
protected handleRequest( config: AxiosRequestConfig ): AxiosRequestConfig {
const url = new URL( buildURL( config ) );
// Store the path in the query string.
if ( config.params instanceof URLSearchParams ) {
config.params.set( this.queryParam, url.pathname );
} else if ( config.params ) {
config.params[ this.queryParam ] = url.pathname;
} else {
config.params = { [ this.queryParam ]: url.pathname };
}
// Store the URL without the path now that it's in the query string.
url.pathname = '';
config.url = url.toString();
delete config.baseURL;
return config;
}
}

View File

@ -1,5 +1,10 @@
import { AxiosRequestConfig } from 'axios'; import { AxiosRequestConfig } from 'axios';
// @ts-ignore
import buildFullPath = require( 'axios/lib/core/buildFullPath' );
// @ts-ignore
import appendParams = require( 'axios/lib/helpers/buildURL' );
/** /**
* Given an Axios request config this function generates the URL that Axios will * Given an Axios request config this function generates the URL that Axios will
* use to make the request. * use to make the request.
@ -8,17 +13,16 @@ import { AxiosRequestConfig } from 'axios';
* @return {string} The merged URL. * @return {string} The merged URL.
*/ */
export function buildURL( request: AxiosRequestConfig ): string { export function buildURL( request: AxiosRequestConfig ): string {
const base = request.baseURL || ''; return buildFullPath( request.baseURL, request.url );
if ( ! request.url ) { }
return base;
} /**
* Given an Axios request config this function generates the URL that Axios will
// Axios ignores the base when the URL is absolute. * use to make the request with the query parameters included.
const url = request.url; *
if ( ! base || url.match( /^([a-z][a-z\d+\-.]*:)?\/\/[^\/]/i ) ) { * @param {AxiosRequestConfig} request The Axios request we're building the URL for.
return url; * @return {string} The merged URL.
} */
export function buildURLWithParams( request: AxiosRequestConfig ): string {
// Remove trailing slashes from the base and leading slashes from the URL so we can combine them consistently. return appendParams( buildURL( request ), request.params, request.paramsSerializer );
return base.replace( /\/+$/, '' ) + '/' + url.replace( /^\/+/, '' );
} }

View File

@ -1,39 +1,141 @@
import { HTTPClient } from './http-client'; import { HTTPClient } from './http-client';
import { AxiosClient, AxiosOAuthInterceptor } from './axios'; import { AxiosClient, AxiosOAuthInterceptor } from './axios';
import { AxiosRequestConfig } from 'axios';
import { AxiosInterceptor } from './axios/axios-interceptor';
import { AxiosURLToQueryInterceptor } from './axios/axios-url-to-query-interceptor';
/** /**
* A class for generating HTTPClient instances with desired configurations. * These types describe the shape of the different auth methods our factory supports.
*/
type OAuthMethod = {
type: 'oauth',
key: string,
secret: string,
};
type BasicAuthMethod = {
type: 'basic',
username: string,
password: string,
}
/**
* An interface for describing the shape of a client to create using the factory.
*/
interface BuildParams {
wpURL: string,
useIndexPermalinks?: boolean,
auth?: OAuthMethod | BasicAuthMethod,
}
/**
* A factory for generating an HTTPClient with a desired configuration.
*/ */
export class HTTPClientFactory { export class HTTPClientFactory {
/** /**
* Creates a new client instance prepared for basic auth. * The configuration object describing the client we're trying to create.
* *
* @param {string} apiURL * @private
* @param {string} username
* @param {string} password
* @return {HTTPClient} An HTTP client configured for OAuth requests.
*/ */
public static withBasicAuth( apiURL: string, username: string, password: string ): HTTPClient { private clientConfig: BuildParams;
return new AxiosClient(
{ private constructor( wpURL: string ) {
baseURL: apiURL, this.clientConfig = { wpURL };
auth: { username, password },
},
);
} }
/** /**
* Creates a new client instance prepared for oauth. * Creates a new factory that can be used to build clients.
* *
* @param {string} apiURL * @param {string} wpURL The root URL of the WordPress installation we're querying.
* @param {string} consumerKey * @return {HTTPClientFactory} The new factory instance.
* @param {string} consumerSecret
* @return {HTTPClient} An HTTP client configured for OAuth requests.
*/ */
public static withOAuth( apiURL: string, consumerKey: string, consumerSecret: string ): HTTPClient { public static build( wpURL: string ): HTTPClientFactory {
return new AxiosClient( return new HTTPClientFactory( wpURL );
{ baseURL: apiURL }, }
[ new AxiosOAuthInterceptor( consumerKey, consumerSecret ) ],
); /**
* Configures the client to utilize OAuth.
*
* @param {string} key The OAuth consumer key to use.
* @param {string} secret The OAuth consumer secret to use.
* @return {HTTPClientFactory} This factory.
*/
public withOAuth( key: string, secret: string ): this {
this.clientConfig.auth = { type: 'oauth', key, secret };
return this;
}
/**
* Configures the client to utilize basic auth.
*
* @param {string} username The WordPress username to use.
* @param {string} password The password for the WordPress user.
* @return {HTTPClientFactory} This factory.
*/
public withBasicAuth( username: string, password: string ): this {
this.clientConfig.auth = { type: 'basic', username, password };
return this;
}
/**
* Configures the client to use index permalinks.
*
* @return {HTTPClientFactory} This factory.
*/
public withIndexPermalinks(): this {
this.clientConfig.useIndexPermalinks = true;
return this;
}
/**
* Configures the client to use query permalinks.
*
* @return {HTTPClientFactory} This factory.
*/
public withoutIndexPermalinks(): this {
this.clientConfig.useIndexPermalinks = false;
return this;
}
/**
* Creates a client instance using the configuration stored within.
*
* @return {HTTPClient} The created client.
*/
public create(): HTTPClient {
const axiosConfig: AxiosRequestConfig = {};
const interceptors: AxiosInterceptor[] = [];
axiosConfig.baseURL = this.clientConfig.wpURL;
if ( ! axiosConfig.baseURL.endsWith( '/' ) ) {
axiosConfig.baseURL += '/';
}
if ( this.clientConfig.useIndexPermalinks ) {
axiosConfig.baseURL += 'wp-json/';
} else {
interceptors.push( new AxiosURLToQueryInterceptor( 'rest_route' ) );
}
if ( this.clientConfig.auth ) {
switch ( this.clientConfig.auth.type ) {
case 'basic':
axiosConfig.auth = {
username: this.clientConfig.auth.username,
password: this.clientConfig.auth.password,
};
break;
case 'oauth':
interceptors.push(
new AxiosOAuthInterceptor(
this.clientConfig.auth.key,
this.clientConfig.auth.secret,
),
);
break;
}
}
return new AxiosClient( axiosConfig, interceptors );
} }
} }

View File

@ -1,11 +1,9 @@
import { HTTPClientFactory } from '@woocommerce/api'; import { HTTPClientFactory } from '@woocommerce/api';
const config = require( 'config' ); const config = require( 'config' );
const httpClient = HTTPClientFactory.withBasicAuth( const httpClient = HTTPClientFactory.build( config.get( 'url' ) )
config.get( 'url' ) + '/wp-json', .withBasicAuth( config.get( 'users.admin.username' ), config.get( 'users.admin.password' ) )
config.get( 'users.admin.username' ), .create();
config.get( 'users.admin.password' ),
);
import { simpleProductFactory } from './factories/simple-product'; import { simpleProductFactory } from './factories/simple-product';