Refactored the HTTP layer to be more API-agnostic

Since we're going to be adding more services and CRUD actions it makes sense for our HTTP services to be less tied to the specific REST API implementation.
This commit is contained in:
Christopher Allford 2020-09-04 11:27:34 -07:00
parent c75f0f8886
commit a875ecb083
30 changed files with 360 additions and 238 deletions

View File

@ -15,9 +15,6 @@ module.exports = {
'plugin:@wordpress/eslint-plugin/recommended-with-formatting' 'plugin:@wordpress/eslint-plugin/recommended-with-formatting'
], ],
overrides: [ overrides: [
{
'files': [ '**/*.ts' ]
},
{ {
'files': [ 'files': [
'**/*.spec.ts', '**/*.spec.ts',

View File

@ -1,5 +1,6 @@
module.exports = { module.exports = {
preset: 'ts-jest', preset: 'ts-jest',
rootDir: 'src',
testEnvironment: 'node', testEnvironment: 'node',
testPathIgnorePatterns: [ '/node_modules/', '/dist/' ], testPathIgnorePatterns: [ '/node_modules/', '/dist/' ],
}; };

View File

@ -3,14 +3,14 @@ import { Model } from './model';
/** /**
* An interface for implementing adapters to create models. * An interface for implementing adapters to create models.
*/ */
export interface Adapter<T extends Model> { export interface Adapter< T extends Model > {
/** /**
* Creates a model or array of models using a service.. * Creates a model or array of models using a service..
* *
* @param {Model|Model[]} model The model or array of models to create. * @param {Model|Model[]} model The model or array of models to create.
* @return {Promise} Resolves to the created input model or array of models. * @return {Promise} Resolves to the created input model or array of models.
*/ */
create( model: T ): Promise<T>; create( model: T ): Promise< T >;
create( model: T[] ): Promise<T[]>; create( model: T[] ): Promise< T[]>;
create( model: T | T[] ): Promise<T> | Promise<T[]>; create( model: T | T[] ): Promise< T > | Promise< T[]>;
} }

View File

@ -12,7 +12,7 @@ class MockAPI implements APIService {
} }
describe( 'APIModelCreator', () => { describe( 'APIModelCreator', () => {
let adapter: APIAdapter<Model>; let adapter: APIAdapter< Model >;
let mockService: MockAPI; let mockService: MockAPI;
beforeEach( () => { beforeEach( () => {

View File

@ -9,17 +9,17 @@ import { Adapter } from '../adapter';
* @param {Model} model The model that we want to transform. * @param {Model} model The model that we want to transform.
* @return {*} The structured request data for the API. * @return {*} The structured request data for the API.
*/ */
export type APITransformerFn<T extends Model> = ( model: T ) => any; export type APITransformerFn< T extends Model > = ( model: T ) => any;
/** /**
* A class used for creating data models using a supplied API endpoint. * A class used for creating data models using a supplied API endpoint.
*/ */
export class APIAdapter<T extends Model> implements Adapter<T> { export class APIAdapter< T extends Model > implements Adapter< T > {
private readonly endpoint: string; private readonly endpoint: string;
private readonly transformer: APITransformerFn<T>; private readonly transformer: APITransformerFn< T >;
private apiService: APIService | null; private apiService: APIService | null;
public constructor( endpoint: string, transformer: APITransformerFn<T> ) { public constructor( endpoint: string, transformer: APITransformerFn< T > ) {
this.endpoint = endpoint; this.endpoint = endpoint;
this.transformer = transformer; this.transformer = transformer;
this.apiService = null; this.apiService = null;
@ -40,9 +40,9 @@ export class APIAdapter<T extends Model> implements Adapter<T> {
* @param {Model|Model[]} model The model or array of models to create. * @param {Model|Model[]} model The model or array of models to create.
* @return {Promise} Resolves to the created input model or array of models. * @return {Promise} Resolves to the created input model or array of models.
*/ */
public create( model: T ): Promise<T>; public create( model: T ): Promise< T >;
public create( model: T[] ): Promise<T[]>; public create( model: T[] ): Promise< T[]>;
public create( model: T | T[] ): Promise<T> | Promise<T[]> { public create( model: T | T[] ): Promise< T > | Promise< T[]> {
if ( ! this.apiService ) { if ( ! this.apiService ) {
throw new Error( 'An API service must be registered for the adapter to work.' ); throw new Error( 'An API service must be registered for the adapter to work.' );
} }
@ -60,7 +60,7 @@ export class APIAdapter<T extends Model> implements Adapter<T> {
* @param {Model} model The model to create. * @param {Model} model The model to create.
* @return {Promise} Resolves to the created input model. * @return {Promise} Resolves to the created input model.
*/ */
private async createSingle( model: T ): Promise<T> { private async createSingle( model: T ): Promise< T > {
return this.apiService!.post( return this.apiService!.post(
this.endpoint, this.endpoint,
this.transformer( model ), this.transformer( model ),
@ -76,8 +76,8 @@ export class APIAdapter<T extends Model> implements Adapter<T> {
* @param {Model[]} models The array of models to create. * @param {Model[]} models The array of models to create.
* @return {Promise} Resolves to the array of created input models. * @return {Promise} Resolves to the array of created input models.
*/ */
private async createList( models: T[] ): Promise<T[]> { private async createList( models: T[] ): Promise< T[]> {
const promises: Promise<T>[] = []; const promises: Promise< T >[] = [];
for ( const model of models ) { for ( const model of models ) {
promises.push( this.createSingle( model ) ); promises.push( this.createSingle( model ) );
} }

View File

@ -1,7 +1,7 @@
/** /**
* A structured response from the API. * A structured response from the API.
*/ */
export class APIResponse<T = any> { export class APIResponse< T = any > {
public readonly status: number; public readonly status: number;
public readonly headers: any; public readonly headers: any;
public readonly data: T; public readonly data: T;
@ -33,7 +33,7 @@ export class APIError {
* *
* @param {APIResponse} response The response to evaluate. * @param {APIResponse} response The response to evaluate.
*/ */
export function isAPIError( response: APIResponse ): response is APIResponse<APIError> { export function isAPIError( response: APIResponse ): response is APIResponse< APIError > {
return response.status < 200 || response.status >= 400; return response.status < 200 || response.status >= 400;
} }
@ -48,10 +48,10 @@ export interface APIService {
* @param {*} params Any parameters that should be passed in the request. * @param {*} params Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError. * @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/ */
get<T>( get< T >(
endpoint: string, endpoint: string,
params?: any params?: any
): Promise<APIResponse<T>>; ): Promise< APIResponse< T >>;
/** /**
* Performs a POST request against the WordPress API. * Performs a POST request against the WordPress API.
@ -60,10 +60,10 @@ export interface APIService {
* @param {*} data Any parameters that should be passed in the request. * @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError. * @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/ */
post<T>( post< T >(
endpoint: string, endpoint: string,
data?: any data?: any
): Promise<APIResponse<T>>; ): Promise< APIResponse< T >>;
/** /**
* Performs a PUT request against the WordPress API. * Performs a PUT request against the WordPress API.
@ -72,7 +72,7 @@ export interface APIService {
* @param {*} data Any parameters that should be passed in the request. * @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError. * @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/ */
put<T>( endpoint: string, data?: any ): Promise<APIResponse<T>>; put< T >( endpoint: string, data?: any ): Promise< APIResponse< T >>;
/** /**
* Performs a PATCH request against the WordPress API. * Performs a PATCH request against the WordPress API.
@ -81,10 +81,10 @@ export interface APIService {
* @param {*} data Any parameters that should be passed in the request. * @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError. * @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/ */
patch<T>( patch< T >(
endpoint: string, endpoint: string,
data?: any data?: any
): Promise<APIResponse<T>>; ): Promise< APIResponse< T >>;
/** /**
* Performs a DELETE request against the WordPress API. * Performs a DELETE request against the WordPress API.
@ -93,8 +93,8 @@ export interface APIService {
* @param {*} data Any parameters that should be passed in the request. * @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError. * @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/ */
delete<T>( delete< T >(
endpoint: string, endpoint: string,
data?: any data?: any
): Promise<APIResponse<T>>; ): Promise< APIResponse< T >>;
} }

View File

@ -0,0 +1,57 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { APIResponse, APIService } from '../api-service';
import { AxiosOAuthInterceptor } from './axios-oauth-interceptor';
import { AxiosInterceptor } from './axios-interceptor';
import { AxiosResponseInterceptor } from './axios-response-interceptor';
/**
* An API service implementation that uses Axios to make requests to the WordPress API.
*/
export class AxiosAPIService implements APIService {
private readonly client: AxiosInstance;
private readonly interceptors: AxiosInterceptor[];
public constructor( config: AxiosRequestConfig, interceptors: AxiosInterceptor[] = [] ) {
this.client = axios.create( config );
this.interceptors = interceptors;
for ( const interceptor of this.interceptors ) {
interceptor.start( this.client );
}
}
/**
* Creates a new Axios API Service using OAuth 1.0a one-legged authentication.
*
* @param {string} apiURL The base URL for the API requests to be sent.
* @param {string} consumerKey The OAuth consumer key.
* @param {string} consumerSecret The OAuth consumer secret.
* @return {AxiosAPIService} The created service.
*/
public static createUsingOAuth( apiURL: string, consumerKey: string, consumerSecret: string ): AxiosAPIService {
return new AxiosAPIService(
{ baseURL: apiURL },
[
new AxiosOAuthInterceptor( consumerKey, consumerSecret ),
new AxiosResponseInterceptor(),
],
);
}
/**
* Creates a new Axios API Service using basic authentication.
*
* @param {string} apiURL The base URL for the API requests to be sent.
* @param {string} username The username for authentication.
* @param {string} password The password for authentication.
* @return {AxiosAPIService} The created service.
*/
public static createUsingBasicAuth( apiURL: string, username: string, password: string ): AxiosAPIService {
return new AxiosAPIService(
{
baseURL: apiURL,
auth: { username, password },
},
[ new AxiosResponseInterceptor() ],
);
}
}

View File

@ -3,17 +3,17 @@ import { Adapter } from './adapter';
import { Product } from '../models/product'; import { Product } from '../models/product';
import { SimpleProduct } from '../models/simple-product'; import { SimpleProduct } from '../models/simple-product';
class MockAdapter implements Adapter<Product> { class MockAdapter implements Adapter< Product > {
public create = jest.fn(); public create = jest.fn();
} }
describe( 'ModelFactory', () => { describe( 'ModelFactory', () => {
let mockAdapter: MockAdapter; let mockAdapter: MockAdapter;
let factory: ModelFactory<Product>; let factory: ModelFactory< Product >;
beforeEach( () => { beforeEach( () => {
mockAdapter = new MockAdapter(); mockAdapter = new MockAdapter();
factory = ModelFactory.define<Product, any, ModelFactory<Product>>( factory = ModelFactory.define< Product, any, ModelFactory< Product >>(
( { params } ) => { ( { params } ) => {
return new SimpleProduct( params ); return new SimpleProduct( params );
}, },

View File

@ -5,15 +5,15 @@ import { Adapter } from './adapter';
/** /**
* A factory that can be used to create models using an adapter. * A factory that can be used to create models using an adapter.
*/ */
export class ModelFactory<T extends Model, I = any> extends Factory<T, I> { export class ModelFactory< T extends Model, I = any > extends Factory< T, I > {
private adapter: Adapter<T> | null = null; private adapter: Adapter< T > | null = null;
/** /**
* Sets the adapter that the factory will use to create models. * Sets the adapter that the factory will use to create models.
* *
* @param {Adapter|null} adapter * @param {Adapter|null} adapter
*/ */
public setAdapter( adapter: Adapter<T> | null ): void { public setAdapter( adapter: Adapter< T > | null ): void {
this.adapter = adapter; this.adapter = adapter;
} }
@ -24,7 +24,7 @@ export class ModelFactory<T extends Model, I = any> extends Factory<T, I> {
* @param {BuildOptions} options The options to be used in the builder. * @param {BuildOptions} options The options to be used in the builder.
* @return {Promise} Resolves to the created model. * @return {Promise} Resolves to the created model.
*/ */
public create( params?: DeepPartial<T>, options?: BuildOptions<T, I> ): Promise<T> { public create( params?: DeepPartial< T >, options?: BuildOptions< T, I > ): Promise< T > {
if ( ! this.adapter ) { if ( ! this.adapter ) {
throw new Error( 'The factory has no adapter to create using.' ); throw new Error( 'The factory has no adapter to create using.' );
} }
@ -41,7 +41,7 @@ export class ModelFactory<T extends Model, I = any> extends Factory<T, I> {
* @param {BuildOptions} options The options to be used in the builder. * @param {BuildOptions} options The options to be used in the builder.
* @return {Promise} Resolves to the created model. * @return {Promise} Resolves to the created model.
*/ */
public createList( number: number, params?: DeepPartial<T>, options?: BuildOptions<T, I> ): Promise<T[]> { public createList( number: number, params?: DeepPartial< T >, options?: BuildOptions< T, I > ): Promise< T[]> {
if ( ! this.adapter ) { if ( ! this.adapter ) {
throw new Error( 'The factory has no adapter to create using.' ); throw new Error( 'The factory has no adapter to create using.' );
} }

View File

@ -12,7 +12,7 @@ describe( 'ModelRegistry', () => {
} ); } );
it( 'should register factories once', () => { it( 'should register factories once', () => {
const factory = ModelFactory.define<Product, any, ModelFactory<Product>>( ( { params } ) => { const factory = ModelFactory.define< Product, any, ModelFactory< Product >>( ( { params } ) => {
return new SimpleProduct( params ); return new SimpleProduct( params );
} ); } );
@ -29,7 +29,7 @@ describe( 'ModelRegistry', () => {
} ); } );
it( 'should register adapters once', () => { it( 'should register adapters once', () => {
const adapter = new APIAdapter<Product>( '', ( model ) => model ); const adapter = new APIAdapter< Product >( '', ( model ) => model );
expect( factoryRegistry.getAdapter( SimpleProduct, AdapterTypes.API ) ).toBeNull(); expect( factoryRegistry.getAdapter( SimpleProduct, AdapterTypes.API ) ).toBeNull();

View File

@ -2,7 +2,7 @@ import { Adapter } from './adapter';
import { Model } from './model'; import { Model } from './model';
import { ModelFactory } from './model-factory'; import { ModelFactory } from './model-factory';
type Registry<T> = { [key: string ]: T }; type Registry< T > = { [key: string ]: T };
/** /**
* The types of adapters that can be stored in the registry. * The types of adapters that can be stored in the registry.
@ -20,8 +20,8 @@ export enum AdapterTypes {
* A registry that allows for us to easily manage all of our factories and related state. * A registry that allows for us to easily manage all of our factories and related state.
*/ */
export class ModelRegistry { export class ModelRegistry {
private readonly factories: Registry<ModelFactory<any>> = {}; private readonly factories: Registry< ModelFactory< any >> = {};
private readonly adapters: { [key in AdapterTypes]: Registry<Adapter<any>> } = { private readonly adapters: { [key in AdapterTypes]: Registry< Adapter< any >> } = {
api: {}, api: {},
custom: {}, custom: {},
}; };
@ -32,7 +32,7 @@ export class ModelRegistry {
* @param {Function} modelClass The class of model we're registering the factory for. * @param {Function} modelClass The class of model we're registering the factory for.
* @param {ModelFactory} factory The factory that we're registering. * @param {ModelFactory} factory The factory that we're registering.
*/ */
public registerFactory<T extends Model>( modelClass: new () => T, factory: ModelFactory<T> ): void { public registerFactory< T extends Model >( modelClass: new () => T, factory: ModelFactory< T > ): void {
if ( this.factories.hasOwnProperty( modelClass.name ) ) { if ( this.factories.hasOwnProperty( modelClass.name ) ) {
throw new Error( 'A factory of this type has already been registered for the model class.' ); throw new Error( 'A factory of this type has already been registered for the model class.' );
} }
@ -45,7 +45,7 @@ export class ModelRegistry {
* *
* @param {Function} modelClass The class of model for the factory we're fetching. * @param {Function} modelClass The class of model for the factory we're fetching.
*/ */
public getFactory<T extends Model>( modelClass: new () => T ): ModelFactory<T> | null { public getFactory< T extends Model >( modelClass: new () => T ): ModelFactory< T > | null {
if ( this.factories.hasOwnProperty( modelClass.name ) ) { if ( this.factories.hasOwnProperty( modelClass.name ) ) {
return this.factories[ modelClass.name ]; return this.factories[ modelClass.name ];
} }
@ -60,7 +60,7 @@ export class ModelRegistry {
* @param {AdapterTypes} type The type of adapter that we're registering. * @param {AdapterTypes} type The type of adapter that we're registering.
* @param {Adapter} adapter The adapter that we're registering. * @param {Adapter} adapter The adapter that we're registering.
*/ */
public registerAdapter<T extends Model>( modelClass: new () => T, type: AdapterTypes, adapter: Adapter<T> ): void { public registerAdapter< T extends Model >( modelClass: new () => T, type: AdapterTypes, adapter: Adapter< T > ): void {
if ( this.adapters[ type ].hasOwnProperty( modelClass.name ) ) { if ( this.adapters[ type ].hasOwnProperty( modelClass.name ) ) {
throw new Error( 'An adapter of this type has already been registered for the model class.' ); throw new Error( 'An adapter of this type has already been registered for the model class.' );
} }
@ -74,7 +74,7 @@ export class ModelRegistry {
* @param {Function} modelClass The class of the model for the adapter we're fetching. * @param {Function} modelClass The class of the model for the adapter we're fetching.
* @param {AdapterTypes} type The type of adapter we're fetching. * @param {AdapterTypes} type The type of adapter we're fetching.
*/ */
public getAdapter<T extends Model>( modelClass: new () => T, type: AdapterTypes ): Adapter<T> | null { public getAdapter< T extends Model >( modelClass: new () => T, type: AdapterTypes ): Adapter< T > | null {
if ( this.adapters[ type ].hasOwnProperty( modelClass.name ) ) { if ( this.adapters[ type ].hasOwnProperty( modelClass.name ) ) {
return this.adapters[ type ][ modelClass.name ]; return this.adapters[ type ][ modelClass.name ];
} }
@ -87,7 +87,7 @@ export class ModelRegistry {
* *
* @param {AdapterTypes} type The type of adapters to fetch. * @param {AdapterTypes} type The type of adapters to fetch.
*/ */
public getAdapters( type: AdapterTypes ): Adapter<any>[] { public getAdapters( type: AdapterTypes ): Adapter< any >[] {
return Object.values( this.adapters[ type ] ); return Object.values( this.adapters[ type ] );
} }
@ -97,7 +97,7 @@ export class ModelRegistry {
* @param {Function} modelClass The class of the model factory we're changing. * @param {Function} modelClass The class of the model factory we're changing.
* @param {AdapterTypes} type The type of adapter to set. * @param {AdapterTypes} type The type of adapter to set.
*/ */
public changeFactoryAdapter<T extends Model>( modelClass: new () => T, type: AdapterTypes ): void { public changeFactoryAdapter< T extends Model >( modelClass: new () => T, type: AdapterTypes ): void {
const factory = this.getFactory( modelClass ); const factory = this.getFactory( modelClass );
if ( ! factory ) { if ( ! factory ) {
throw new Error( 'No factory defined for this model class.' ); throw new Error( 'No factory defined for this model class.' );

View File

@ -6,7 +6,7 @@ import { DeepPartial } from 'fishery';
export abstract class Model { export abstract class Model {
private _id: number = 0; private _id: number = 0;
protected constructor( partial: DeepPartial<any> = {} ) { protected constructor( partial: DeepPartial< any > = {} ) {
Object.assign( this, partial ); Object.assign( this, partial );
} }

View File

@ -8,7 +8,7 @@ export abstract class Product extends Model {
public readonly name: string = ''; public readonly name: string = '';
public readonly regularPrice: string = ''; public readonly regularPrice: string = '';
protected constructor( partial: DeepPartial<Product> = {} ) { protected constructor( partial: DeepPartial< Product > = {} ) {
super( partial ); super( partial );
Object.assign( this, partial ); Object.assign( this, partial );
} }

View File

@ -6,7 +6,7 @@ import { APIAdapter } from '../framework/api/api-adapter';
import faker from 'faker/locale/en'; import faker from 'faker/locale/en';
export class SimpleProduct extends Product { export class SimpleProduct extends Product {
public constructor( partial: DeepPartial<SimpleProduct> = {} ) { public constructor( partial: DeepPartial< SimpleProduct > = {} ) {
super( partial ); super( partial );
Object.assign( this, partial ); Object.assign( this, partial );
} }
@ -22,7 +22,7 @@ export function registerSimpleProduct( registry: ModelRegistry ): void {
return; return;
} }
const factory = ModelFactory.define<SimpleProduct, any, ModelFactory<SimpleProduct>>( const factory = ModelFactory.define< SimpleProduct, any, ModelFactory< SimpleProduct >>(
( { params } ) => { ( { params } ) => {
return new SimpleProduct( return new SimpleProduct(
{ {
@ -34,7 +34,7 @@ export function registerSimpleProduct( registry: ModelRegistry ): void {
); );
registry.registerFactory( SimpleProduct, factory ); registry.registerFactory( SimpleProduct, factory );
const apiAdapter = new APIAdapter<SimpleProduct>( const apiAdapter = new APIAdapter< SimpleProduct >(
'/wc/v3/products', '/wc/v3/products',
( model ) => { ( model ) => {
return { return {

View File

@ -16,7 +16,7 @@ export function initializeUsingOAuth(
consumerKey: string, consumerKey: string,
consumerSecret: string, consumerSecret: string,
): void { ): void {
const adapters = registry.getAdapters( AdapterTypes.API ) as APIAdapter<any>[]; const adapters = registry.getAdapters( AdapterTypes.API ) as APIAdapter< any >[];
if ( ! adapters.length ) { if ( ! adapters.length ) {
return; return;
} }
@ -43,7 +43,7 @@ export function initializeUsingBasicAuth(
username: string, username: string,
password: string, password: string,
): void { ): void {
const adapters = registry.getAdapters( AdapterTypes.API ) as APIAdapter<any>[]; const adapters = registry.getAdapters( AdapterTypes.API ) as APIAdapter< any >[];
if ( ! adapters.length ) { if ( ! adapters.length ) {
return; return;
} }

View File

@ -18,18 +18,18 @@
"files": [ "files": [
"/dist/", "/dist/",
"!*.tsbuildinfo", "!*.tsbuildinfo",
"!*.spec.js", "!__tests__/",
"!*.spec.d.ts", "!__mocks__/",
"!*.test.js", "!__snapshops__/"
"!*.test.d.ts"
], ],
"sideEffects": false, "sideEffects": false,
"scripts": { "scripts": {
"test": "jest",
"clean": "rm -rf ./dist ./tsconfig.tsbuildinfo", "clean": "rm -rf ./dist ./tsconfig.tsbuildinfo",
"compile": "tsc -b", "compile": "tsc -b",
"build": "npm run clean && npm run compile", "build": "npm run clean && npm run compile",
"prepare": "npm run build" "prepare": "npm run build",
"lint": "eslint src",
"test": "jest"
}, },
"dependencies": { "dependencies": {
"axios": "0.19.2", "axios": "0.19.2",

View File

@ -1,127 +0,0 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { APIResponse, APIService } from '../api-service';
import { AxiosOAuthInterceptor } from './axios-oauth-interceptor';
import { AxiosInterceptor } from './axios-interceptor';
import { AxiosResponseInterceptor } from './axios-response-interceptor';
/**
* An API service implementation that uses Axios to make requests to the WordPress API.
*/
export class AxiosAPIService implements APIService {
private readonly client: AxiosInstance;
private readonly interceptors: AxiosInterceptor[];
public constructor( config: AxiosRequestConfig, interceptors: AxiosInterceptor[] = [] ) {
this.client = axios.create( config );
this.interceptors = interceptors;
for ( const interceptor of this.interceptors ) {
interceptor.start( this.client );
}
}
/**
* Creates a new Axios API Service using OAuth 1.0a one-legged authentication.
*
* @param {string} apiURL The base URL for the API requests to be sent.
* @param {string} consumerKey The OAuth consumer key.
* @param {string} consumerSecret The OAuth consumer secret.
* @return {AxiosAPIService} The created service.
*/
public static createUsingOAuth( apiURL: string, consumerKey: string, consumerSecret: string ): AxiosAPIService {
return new AxiosAPIService(
{ baseURL: apiURL },
[
new AxiosOAuthInterceptor( consumerKey, consumerSecret ),
new AxiosResponseInterceptor(),
],
);
}
/**
* Creates a new Axios API Service using basic authentication.
*
* @param {string} apiURL The base URL for the API requests to be sent.
* @param {string} username The username for authentication.
* @param {string} password The password for authentication.
* @return {AxiosAPIService} The created service.
*/
public static createUsingBasicAuth( apiURL: string, username: string, password: string ): AxiosAPIService {
return new AxiosAPIService(
{
baseURL: apiURL,
auth: { username, password },
},
[ new AxiosResponseInterceptor() ],
);
}
/**
* Performs a GET request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} params Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
public get<T>(
endpoint: string,
params?: any,
): Promise<APIResponse<T>> {
return this.client.get( endpoint, { params } );
}
/**
* Performs a POST request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
public post<T>(
endpoint: string,
data?: any,
): Promise<APIResponse<T>> {
return this.client.post( endpoint, data );
}
/**
* Performs a PUT request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
public put<T>(
endpoint: string,
data?: any,
): Promise<APIResponse<T>> {
return this.client.put( endpoint, data );
}
/**
* Performs a PATCH request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
public patch<T>(
endpoint: string,
data?: any,
): Promise<APIResponse<T>> {
return this.client.patch( endpoint, data );
}
/**
* Performs a DELETE request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
public delete<T>(
endpoint: string,
data?: any,
): Promise<APIResponse<T>> {
return this.client.delete( endpoint, { data } );
}
}

View File

@ -1,39 +0,0 @@
import { AxiosResponse } from 'axios';
import { APIResponse, APIError } from '../api-service';
import { AxiosInterceptor } from './axios-interceptor';
export class AxiosResponseInterceptor extends AxiosInterceptor {
/**
* Transforms the Axios response into our API response to be consumed in a consistent manner.
*
* @param {AxiosResponse} response The respons ethat we need to transform.
* @return {Promise} A promise containing the APIResponse.
*/
protected onResponseSuccess( response: AxiosResponse ): Promise<APIResponse> {
return Promise.resolve<APIResponse>(
new APIResponse( response.status, response.headers, response.data ),
);
}
/**
* Transforms HTTP errors into an API error if the error came from the API.
*
* @param {*} error The error that was caught.
*/
protected onResponseRejected( error: any ): Promise<APIResponse> {
// Only transform API errors.
if ( ! error.response ) {
throw error;
}
throw new APIResponse(
error.response.status,
error.response.headers,
new APIError(
error.response.data.code,
error.response.data.message,
error.response.data.data,
),
);
}
}

View File

@ -0,0 +1,34 @@
import moxios from 'moxios';
import { AxiosClient } from '../axios-client';
import { AxiosResponseInterceptor } from '../axios-response-interceptor';
import { HTTPResponse } from '../../http-client';
describe( 'AxiosClient', () => {
let httpClient: AxiosClient;
beforeEach( () => {
moxios.install();
} );
afterEach( () => {
moxios.uninstall();
} );
it( 'should execute interceptors', async () => {
httpClient = new AxiosClient(
{ baseURL: 'http://test.test' },
[ new AxiosResponseInterceptor() ],
);
moxios.stubOnce( 'GET', '/test', {
status: 200,
headers: {
'Content-Type': 'application/json',
},
responseText: JSON.stringify( { test: 'value' } ),
} );
const response = await httpClient.get( '/test' );
expect( response ).toBeInstanceOf( HTTPResponse );
} );
} );

View File

@ -1,6 +1,6 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import moxios from 'moxios'; import moxios from 'moxios';
import { AxiosOAuthInterceptor } from './axios-oauth-interceptor'; import { AxiosOAuthInterceptor } from '../axios-oauth-interceptor';
describe( 'AxiosOAuthInterceptor', () => { describe( 'AxiosOAuthInterceptor', () => {
let apiAuthInterceptor: AxiosOAuthInterceptor; let apiAuthInterceptor: AxiosOAuthInterceptor;

View File

@ -1,7 +1,6 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import moxios from 'moxios'; import moxios from 'moxios';
import { APIResponse, APIError } from '../api-service'; import { AxiosResponseInterceptor } from '../axios-response-interceptor';
import { AxiosResponseInterceptor } from './axios-response-interceptor';
describe( 'AxiosResponseInterceptor', () => { describe( 'AxiosResponseInterceptor', () => {
let apiResponseInterceptor: AxiosResponseInterceptor; let apiResponseInterceptor: AxiosResponseInterceptor;
@ -19,7 +18,7 @@ describe( 'AxiosResponseInterceptor', () => {
moxios.uninstall(); moxios.uninstall();
} ); } );
it( 'should transform responses into APIResponse', async () => { it( 'should transform responses into an HTTPResponse', async () => {
moxios.stubOnce( 'GET', 'http://test.test', { moxios.stubOnce( 'GET', 'http://test.test', {
status: 200, status: 200,
headers: { headers: {
@ -41,21 +40,34 @@ describe( 'AxiosResponseInterceptor', () => {
} ); } );
} ); } );
it( 'should transform response errors into APIError', async () => { it( 'should transform error responses into an HTTPResponse', async () => {
moxios.stubOnce( 'GET', 'http://test.test', { moxios.stubOnce( 'GET', 'http://test.test', {
status: 404, status: 404,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
responseText: JSON.stringify( { code: 'error_code', message: 'value', data: null } ), responseText: JSON.stringify( { code: 'error_code', message: 'value' } ),
} ); } );
const response = await axiosInstance.get( 'http://test.test' );
expect( response ).toMatchObject( {
status: 404,
headers: {
'content-type': 'application/json',
},
data: {
code: 'error_code',
message: 'value',
},
} );
} );
it( 'should bubble non-response errors', async () => {
moxios.stubTimeout( 'http://test.test' );
await expect( axiosInstance.get( 'http://test.test' ) ).rejects.toMatchObject( await expect( axiosInstance.get( 'http://test.test' ) ).rejects.toMatchObject(
new APIResponse( new Error( 'timeout of 0ms exceeded' ),
404,
{ 'content-type': 'application/json' },
new APIError( 'error_code', 'value', null ),
),
); );
} ); } );
} ); } );

View File

@ -0,0 +1,89 @@
import { HTTPClient, HTTPResponse } from '../http-client';
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { AxiosInterceptor } from './axios-interceptor';
/**
* An HTTPClient implementation that uses Axios to make requests.
*/
export class AxiosClient implements HTTPClient {
private readonly client: AxiosInstance;
private readonly interceptors: AxiosInterceptor[];
public constructor( config: AxiosRequestConfig, interceptors: AxiosInterceptor[] = [] ) {
this.client = axios.create( config );
this.interceptors = interceptors;
for ( const interceptor of this.interceptors ) {
interceptor.start( this.client );
}
}
/**
* Performs a GET request.
*
* @param {string} path The path we should send the request to.
* @param {*} params Any parameters that should be passed in the request.
* @return {Promise} Resolves to an HTTPResponse.
*/
public get< T >(
path: string,
params?: any,
): Promise< HTTPResponse< T >> {
return this.client.get( path, { params } );
}
/**
* Performs a POST request.
*
* @param {string} path The path we should send the request to.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an HTTPResponse.
*/
public post< T >(
path: string,
data?: any,
): Promise< HTTPResponse< T >> {
return this.client.post( path, data );
}
/**
* Performs a PUT request.
*
* @param {string} path The path we should send the request to.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an HTTPResponse.
*/
public put< T >(
path: string,
data?: any,
): Promise< HTTPResponse< T >> {
return this.client.put( path, data );
}
/**
* Performs a PATCH request.
*
* @param {string} path The path we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an HTTPResponse.
*/
public patch< T >(
path: string,
data?: any,
): Promise< HTTPResponse< T >> {
return this.client.patch( path, data );
}
/**
* Performs a DELETE request.
*
* @param {string} path The path we should send the request to.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an HTTPResponse.
*/
public delete< T >(
path: string,
data?: any,
): Promise< HTTPResponse< T >> {
return this.client.delete( path, { data } );
}
}

View File

@ -0,0 +1,34 @@
import { AxiosResponse } from 'axios';
import { AxiosInterceptor } from './axios-interceptor';
import { HTTPResponse } from '../http-client';
export class AxiosResponseInterceptor extends AxiosInterceptor {
/**
* Transforms the Axios response into our HTTP response.
*
* @param {AxiosResponse} response The response that we need to transform.
* @return {Promise} A promise containing the HTTPResponse.
*/
protected onResponseSuccess( response: AxiosResponse ): Promise< HTTPResponse > {
return Promise.resolve< HTTPResponse >(
new HTTPResponse( response.status, response.headers, response.data ),
);
}
/**
* Axios throws HTTP errors so we need to eat those errors and pass them normally.
*
* @param {*} error The error that was caught.
* @return {Promise} A promise containing the HTTPResponse.
*/
protected onResponseRejected( error: any ): Promise< HTTPResponse > {
// Convert HTTP response errors into a form that we can handle them with.
if ( error.response ) {
return Promise.resolve< HTTPResponse >(
new HTTPResponse( error.response.status, error.response.headers, error.response.data ),
);
}
throw error;
}
}

View File

@ -0,0 +1,64 @@
/**
* A structured response from the HTTP client.
*/
export class HTTPResponse< T = any > {
public readonly status: number;
public readonly headers: any;
public readonly data: T;
public constructor( status: number, headers: any, data: T ) {
this.status = status;
this.headers = headers;
this.data = data;
}
}
/**
* An interface for implementing clients for making HTTP requests..
*/
export interface HTTPClient {
/**
* Performs a GET request.
*
* @param {string} path The path we should send the request to.
* @param {*} params Any parameters that should be passed in the request.
* @return {Promise} Resolves to an HTTPResponse.
*/
get< T >( path: string, params?: any ): Promise< HTTPResponse< T > >;
/**
* Performs a POST request.
*
* @param {string} path The path we should send the request to.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an HTTPResponse.
*/
post< T >( path: string, data?: any ): Promise< HTTPResponse< T > >;
/**
* Performs a PUT request.
*
* @param {string} path The path we should send the request to.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an HTTPResponse.
*/
put< T >( path: string, data?: any ): Promise< HTTPResponse< T > >;
/**
* Performs a PATCH request.
*
* @param {string} path The path we should send the request to.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an HTTPResponse.
*/
patch< T >( path: string, data?: any ): Promise< HTTPResponse< T > >;
/**
* Performs a DELETE request.
*
* @param {string} path The path we should send the request to.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an HTTPResponse.
*/
delete< T >( path: string, data?: any ): Promise< HTTPResponse< T > >;
}