Centralized the CRUD actions into a single kind of repository that can be easily used in every adapter case

This commit is contained in:
Christopher Allford 2020-09-17 09:43:43 -07:00
parent 3a7c96b7cd
commit 6c230ca7b3
12 changed files with 163 additions and 230 deletions

View File

@ -0,0 +1,53 @@
import { Model } from '../../models/model';
import { ModelRepository } from '../model-repository';
import Mock = jest.Mock;
class DummyModel extends Model {
public name: string = '';
public onCreated( data: any ): void {
super.onCreated( data );
this.name = data.name;
}
}
describe( 'ModelRepository', () => {
let dummyModel: DummyModel;
let mockCallback: Mock;
let repository: ModelRepository< DummyModel >;
beforeEach( () => {
dummyModel = new DummyModel();
dummyModel.name = 'test';
mockCallback = jest.fn();
repository = new ModelRepository< DummyModel >( mockCallback, mockCallback, mockCallback, mockCallback );
} );
it( 'should create', async () => {
mockCallback.mockReturnValue( Promise.resolve( dummyModel ) );
await repository.create( dummyModel );
expect( mockCallback ).toHaveBeenCalledWith( dummyModel );
} );
it( 'should read', async () => {
mockCallback.mockReturnValue( Promise.resolve( dummyModel ) );
await repository.read( { id: 'test' } );
expect( mockCallback ).toHaveBeenCalledWith( { id: 'test' } );
} );
it( 'should update', async () => {
mockCallback.mockReturnValue( Promise.resolve( dummyModel ) );
await repository.update( dummyModel );
expect( mockCallback ).toHaveBeenCalledWith( dummyModel );
} );
it( 'should delete', async () => {
mockCallback.mockReturnValue( Promise.resolve( true ) );
await repository.delete( dummyModel );
expect( mockCallback ).toHaveBeenCalledWith( dummyModel );
} );
} );

View File

@ -1,44 +0,0 @@
import { Model } from '../../models/model';
import { ModelTransformer } from '../model-transformer';
class TestData extends Model {
public name: string = '';
public onCreated( data: any ): void {
super.onCreated( data );
this.name = data.name;
}
}
describe( 'ModelTransformer', () => {
let transformer: ModelTransformer< TestData >;
beforeEach( () => {
transformer = new ModelTransformer<TestData>(
( model ) => {
return { id: model.id, name: model.name };
},
( data ) => {
const model = new TestData();
model.name = data.name;
return model;
},
);
} );
it( 'should transform to server', () => {
const model = new TestData();
model.onCreated( { id: 1, name: 'Testing' } );
const transformed = transformer.toServer( model );
expect( transformed ).toEqual( { id: 1, name: 'Testing' } );
} );
it( 'should transform from server', () => {
const transformed = transformer.fromServer( { name: 'Testing' } );
expect( transformed ).toBeInstanceOf( TestData );
expect( transformed.name ).toBe( 'Testing' );
} );
} );

View File

@ -1,49 +0,0 @@
import { HTTPClient, HTTPResponse } from '../../http';
import { mock, MockProxy } from 'jest-mock-extended';
import { RESTRepository } from '../rest-repository';
import { Model } from '../../models/model';
import { ModelTransformer } from '../model-transformer';
class TestData extends Model {
public name: string = '';
public onCreated( data: any ): void {
super.onCreated( data );
this.name = data.name;
}
}
describe( 'RESTRepository', () => {
let httpClient: MockProxy< HTTPClient >;
let repository: RESTRepository< TestData >;
beforeEach( () => {
httpClient = mock< HTTPClient >();
const transformer = new ModelTransformer< TestData >(
( data ) => {
return { transformedName: data.name };
},
() => new TestData(),
);
repository = new RESTRepository< TestData >(
transformer,
{ create: '/testing' },
);
repository.setHTTPClient( httpClient );
} );
it( 'should create', async () => {
httpClient.post.mockReturnValueOnce(
Promise.resolve(
new HTTPResponse( 200, {}, { id: 1, name: 'created' } ),
),
);
const data = new TestData();
data.name = 'testing';
const created = await repository.create( data );
expect( created.name ).toEqual( 'created' );
expect( httpClient.post ).toBeCalledWith( '/testing', { transformedName: 'testing' } );
} );
} );

View File

@ -0,0 +1,83 @@
import { Model } from '../models/model';
type CreateFn< T > = ( model: T ) => Promise< T >;
type ReadFn< T, P > = ( params: P ) => Promise< T >;
type UpdateFn< T > = ( model: T ) => Promise< T >;
type DeleteFn< T > = ( model: T ) => Promise< boolean >;
/**
* The standard parameters for reading a model.
*/
interface DefaultReadParams {
id: string;
}
/**
* An interface for repositories that perform CRUD actions.
*/
export class ModelRepository< T extends Model, ReadParams = DefaultReadParams > {
private readonly createHook: CreateFn< T >;
private readonly readHook: ReadFn< T, ReadParams >;
private readonly updateHook: UpdateFn< T >;
private readonly deleteHook: DeleteFn< T >;
/**
* Creates a new repository instance.
*
* @param {Function} createHook The hook for model creation.
* @param {Function} readHook The hook for model reading.
* @param {Function} updateHook The hook for model updating.
* @param {Function} deleteHook The hook for model deletion.
*/
public constructor(
createHook: CreateFn< T >,
readHook: ReadFn< T, ReadParams >,
updateHook: UpdateFn< T >,
deleteHook: DeleteFn< T >,
) {
this.createHook = createHook;
this.readHook = readHook;
this.updateHook = updateHook;
this.deleteHook = deleteHook;
}
/**
* Creates the given model.
*
* @param {*} model The model that we would like to create.
* @return {Promise} A promise that resolves to the model after creation.
*/
public create( model: T ): Promise< T > {
return this.createHook( model );
}
/**
* Reads the given model.
*
* @param {Object} params The parameters to help with reading the model.
* @return {Promise} A promise that resolves to the model.
*/
public read( params: ReadParams ): Promise< T > {
return this.readHook( params );
}
/**
* Updates the given model.
*
* @param {*} model The model we want to update.
* @return {Promise} A promise that resolves to the model after updating.
*/
public update( model: T ): Promise< T > {
return this.updateHook( model );
}
/**
* Deletes the given model.
*
* @param {*} model The model we want to delete.
* @return {Promise} A promise that resolves to "true" on success.
*/
public delete( model: T ): Promise< boolean > {
return this.deleteHook( model );
}
}

View File

@ -1,37 +0,0 @@
import { Model } from '../models/model';
type ToServerFn< T > = ( model: T ) => any;
type FromServerFn< T > = ( data: any ) => T;
/**
* A class for transforming models between the server representation and the client representation.
*/
export class ModelTransformer< T extends Model > {
private readonly toServerHook: ToServerFn< T >;
private readonly fromServerHook: FromServerFn< T >;
public constructor( toServer: ToServerFn< T >, fromServer: FromServerFn< T > ) {
this.toServerHook = toServer;
this.fromServerHook = fromServer;
}
/**
* Transforms the model to a representation the server will understand.
*
* @param {Model} model The model to transform.
* @return {*} The transformed model.
*/
public toServer( model: T ): any {
return this.toServerHook( model );
}
/**
* Transforms the model from the server's representation to the client model.
*
* @param {*} data The server representation.
* @return {Model} The transformed model.
*/
public fromServer( data: any ): T {
return this.fromServerHook( data );
}
}

View File

@ -1,14 +0,0 @@
import { Model } from '../models/model';
/**
* An interface for repositories that perform CRUD actions.
*/
export interface Repository< T extends Model > {
/**
* Uses the repository to create the given data.
*
* @param {*} data The data that we would like to create.
* @return {Promise} A promise that resolves to the data after creation.
*/
create( data: T ): Promise< T >;
}

View File

@ -1,78 +0,0 @@
import { Repository } from './repository';
import { HTTPClient, HTTPResponse } from '../http';
import { Model } from '../models/model';
import { ModelTransformer } from './model-transformer';
/**
* An interface for describing the endpoints available to the repository.
*
* @typedef {Object} Endpoints
* @property {string} create The creation endpoint.
*/
export interface Endpoints {
create?: string;
}
/**
* A repository for interacting with models via the REST API.
*/
export class RESTRepository< T extends Model > implements Repository< T > {
private httpClient: HTTPClient | null = null;
private readonly transformer: ModelTransformer< T >;
private readonly endpoints: Endpoints;
public constructor(
transformer: ModelTransformer< T >,
endpoints: Endpoints,
) {
this.transformer = transformer;
this.endpoints = endpoints;
}
/**
* Sets the HTTP client that the repository should use for requests.
*
* @param {HTTPClient} httpClient The client to use.
*/
public setHTTPClient( httpClient: HTTPClient | null ): void {
this.httpClient = httpClient;
}
/**
* Creates the data using the REST API.
*
* @param {*} data The model that we would like to create.
* @return {Promise} A promise that resolves to the model after creation.
*/
public async create( data: T ): Promise< T > {
if ( ! this.httpClient ) {
throw new Error( 'There is no HTTP client set to make the request.' );
}
if ( ! this.endpoints.create ) {
throw new Error( 'There is no `create` endpoint defined.' );
}
const endpoint = this.endpoints.create.replace( /{[a-z]+}/i, this.replaceEndpointTokens( data ) );
const transformed = this.transformer.toServer( data );
return this.httpClient.post( endpoint, transformed )
.then( ( response: HTTPResponse ) => {
if ( response.status >= 400 ) {
throw new Error( 'An error has occurred!' );
}
data.onCreated( response.data );
return Promise.resolve( data );
} );
}
/**
* Replaces the tokens in the endpoint according to properties of the data argument.
*
* @param {any} data The model to replace tokens using.
*/
private replaceEndpointTokens( data: any ): ( match: string ) => string {
return ( match: string ) => data[ match ];
}
}

View File

@ -20,7 +20,7 @@ describe( 'AxiosClient', () => {
[ new AxiosResponseInterceptor() ],
);
moxios.stubOnce( 'GET', '/test', {
moxios.stubRequest( '/test', {
status: 200,
headers: {
'Content-Type': 'application/json',

View File

@ -19,7 +19,7 @@ describe( 'AxiosResponseInterceptor', () => {
} );
it( 'should transform responses into an HTTPResponse', async () => {
moxios.stubOnce( 'GET', 'http://test.test', {
moxios.stubRequest( 'http://test.test', {
status: 200,
headers: {
'Content-Type': 'application/json',
@ -30,7 +30,7 @@ describe( 'AxiosResponseInterceptor', () => {
const response = await axiosInstance.get( 'http://test.test' );
expect( response ).toMatchObject( {
status: 200,
statusCode: 200,
headers: {
'content-type': 'application/json',
},
@ -41,7 +41,7 @@ describe( 'AxiosResponseInterceptor', () => {
} );
it( 'should transform error responses into an HTTPResponse', async () => {
moxios.stubOnce( 'GET', 'http://test.test', {
moxios.stubRequest( 'http://test.test', {
status: 404,
headers: {
'Content-Type': 'application/json',
@ -52,7 +52,7 @@ describe( 'AxiosResponseInterceptor', () => {
const response = await axiosInstance.get( 'http://test.test' );
expect( response ).toMatchObject( {
status: 404,
statusCode: 404,
headers: {
'content-type': 'application/json',
},

View File

@ -9,6 +9,12 @@ export class AxiosClient implements HTTPClient {
private readonly client: AxiosInstance;
private readonly interceptors: AxiosInterceptor[];
/**
* Creates a new axios client.
*
* @param {AxiosRequestConfig} config The request configuration.
* @param {AxiosInterceptor[]} interceptors An array of interceptors to apply to the client.
*/
public constructor( config: AxiosRequestConfig, interceptors: AxiosInterceptor[] = [] ) {
this.client = axios.create( config );
this.interceptors = interceptors;

View File

@ -9,6 +9,12 @@ import { AxiosInterceptor } from './axios-interceptor';
export class AxiosOAuthInterceptor extends AxiosInterceptor {
private oauth: OAuth;
/**
* Creates a new interceptor.
*
* @param {string} consumerKey The consumer key of the API key.
* @param {string} consumerSecret The consumer secret of the API key.
*/
public constructor( consumerKey: string, consumerSecret: string ) {
super();

View File

@ -2,12 +2,19 @@
* A structured response from the HTTP client.
*/
export class HTTPResponse< T = any > {
public readonly status: number;
public readonly statusCode: number;
public readonly headers: any;
public readonly data: T;
public constructor( status: number, headers: any, data: T ) {
this.status = status;
/**
* Creates a new HTTP response instance.
*
* @param {number} statusCode The status code from the HTTP response.
* @param {*} headers The headers from the HTTP response.
* @param {*} data The data from the HTTP response.
*/
public constructor( statusCode: number, headers: any, data: T ) {
this.statusCode = statusCode;
this.headers = headers;
this.data = data;
}