From 54f30d96e2fbc1bae59098c61f1bf2280086ce99 Mon Sep 17 00:00:00 2001 From: Francois Lachance-Guillemette Date: Thu, 14 Nov 2019 11:21:32 -0500 Subject: [PATCH 1/2] feat(apihooks): allow hooks on resources and client --- src/APICore.ts | 5 +- src/ConfigurationInterfaces.ts | 2 + src/PlatformClient.ts | 16 +- src/features/APIFeature.ts | 3 + src/features/APIWithHooks.ts | 61 ++++++ src/features/tests/APIWithHooks.spec.ts | 265 ++++++++++++++++++++++++ src/resources/PlatformResources.ts | 4 +- src/resources/Resource.ts | 11 +- src/resources/tests/Resource.spec.ts | 47 ++++- src/tests/PlatformClient.spec.ts | 45 ++++ 10 files changed, 451 insertions(+), 8 deletions(-) create mode 100644 src/features/APIFeature.ts create mode 100644 src/features/APIWithHooks.ts create mode 100644 src/features/tests/APIWithHooks.spec.ts diff --git a/src/APICore.ts b/src/APICore.ts index c35ed2d0c..987918926 100644 --- a/src/APICore.ts +++ b/src/APICore.ts @@ -2,7 +2,10 @@ import {APIConfiguration} from './ConfigurationInterfaces'; import {ResponseHandler} from './handlers/ResponseHandlerInterfaces'; import handleResponse, {defaultResponseHandlers} from './handlers/ResponseHandlers'; -export default class API { +type APIPrototype = typeof API.prototype; +export type IAPI = {[P in keyof APIPrototype]: APIPrototype[P]}; + +export default class API implements IAPI { static orgPlaceholder = '{organizationName}'; private getRequestsController: AbortController; diff --git a/src/ConfigurationInterfaces.ts b/src/ConfigurationInterfaces.ts index 8730ed3a8..315ed8cae 100644 --- a/src/ConfigurationInterfaces.ts +++ b/src/ConfigurationInterfaces.ts @@ -1,3 +1,4 @@ +import {IAPIFeature} from './features/APIFeature'; import {ResponseHandler} from './handlers/ResponseHandlerInterfaces'; export interface APIConfiguration { @@ -5,6 +6,7 @@ export interface APIConfiguration { accessTokenRetriever: () => string; host?: string; responseHandlers?: ResponseHandler[]; + apiFeatures?: IAPIFeature[]; } export interface PlatformClientOptions extends APIConfiguration { diff --git a/src/PlatformClient.ts b/src/PlatformClient.ts index 8aa7df0c1..776054dd7 100644 --- a/src/PlatformClient.ts +++ b/src/PlatformClient.ts @@ -1,6 +1,7 @@ -import API from './APICore'; +import API, {IAPI} from './APICore'; import {APIConfiguration, PlatformClientOptions} from './ConfigurationInterfaces'; import {HostUndefinedError} from './Errors'; +import {IAPIFeature} from './features/APIFeature'; import {ResponseHandlers} from './handlers/ResponseHandlers'; import PlatformResources from './resources/PlatformResources'; @@ -38,10 +39,21 @@ export class PlatformClient extends PlatformResources { throw new HostUndefinedError(); } - this.API = new API(this.apiConfiguration); + const api: IAPI = new API(this.apiConfiguration); + this.API = this.options.apiFeatures + ? this.options.apiFeatures.reduce((current, feature) => feature(current), api) + : api; this.registerAll(); } + withFeatures(...features: IAPIFeature[]): this { + const newInstance = Object.create(this) as this; + return newInstance.constructor({ + ...this.options, + apiFeatures: [...(this.options.apiFeatures || []), ...features], + } as PlatformClientOptions); + } + async initialize() { try { this.tokenInfo = await this.checkToken(); diff --git a/src/features/APIFeature.ts b/src/features/APIFeature.ts new file mode 100644 index 000000000..3b55a5702 --- /dev/null +++ b/src/features/APIFeature.ts @@ -0,0 +1,3 @@ +import {IAPI} from '../APICore'; + +export type IAPIFeature = (api: IAPI) => IAPI; diff --git a/src/features/APIWithHooks.ts b/src/features/APIWithHooks.ts new file mode 100644 index 000000000..918990af1 --- /dev/null +++ b/src/features/APIWithHooks.ts @@ -0,0 +1,61 @@ +import {IAPI} from '../APICore'; + +export interface IAPIHooks { + beforeAnyRequest?: (url: string, args: RequestInit) => void; + afterAnySuccess?: (url: string, args: RequestInit, response: T) => T; + afterAnyException?: (url: string, args: RequestInit, exception: Error) => boolean; +} + +export class APIWithHooks implements IAPI { + constructor(private api: TAPI, private hooks: IAPIHooks) {} + + get organizationId() { + return this.api.organizationId; + } + + async get(url: string, args: RequestInit = {method: 'get'}): Promise { + return this.wrapInGenericHandler(url, args, () => this.api.get(url, args)); + } + + async post( + url: string, + body: any = {}, + args: RequestInit = {method: 'post', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'}} + ): Promise { + return this.wrapInGenericHandler(url, args, () => this.api.post(url, body, args)); + } + + async postForm(url: string, form: FormData, args: RequestInit = {method: 'post', body: form}): Promise { + return this.wrapInGenericHandler(url, args, () => this.api.postForm(url, form, args)); + } + + async put( + url: string, + body: any, + args: RequestInit = {method: 'put', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'}} + ): Promise { + return this.wrapInGenericHandler(url, args, () => this.api.put(url, body, args)); + } + + async delete(url: string, args: RequestInit = {method: 'delete'}): Promise { + return this.wrapInGenericHandler(url, args, () => this.api.delete(url, args)); + } + + abortGetRequests(): void { + this.api.abortGetRequests(); + } + + private async wrapInGenericHandler(url: string, args: RequestInit, request: () => Promise) { + this.hooks.beforeAnyRequest?.(url, args); + + try { + const response = await request(); + this.hooks.afterAnySuccess?.(url, args, response); + return response; + } catch (exception) { + if (!this.hooks.afterAnyException?.(url, args, exception)) { + throw exception; + } + } + } +} diff --git a/src/features/tests/APIWithHooks.spec.ts b/src/features/tests/APIWithHooks.spec.ts new file mode 100644 index 000000000..82642e59d --- /dev/null +++ b/src/features/tests/APIWithHooks.spec.ts @@ -0,0 +1,265 @@ +import {IAPI} from '../../APICore'; +import {APIWithHooks} from '../APIWithHooks'; + +jest.mock('../../APICore'); + +const mockApi = (): jest.Mocked => ({ + organizationId: '🐟', + get: jest.fn().mockImplementation(() => Promise.resolve({})), + post: jest.fn().mockImplementation(() => Promise.resolve({})), + postForm: jest.fn().mockImplementation(() => Promise.resolve({})), + delete: jest.fn().mockImplementation(() => Promise.resolve({})), + put: jest.fn().mockImplementation(() => Promise.resolve({})), + abortGetRequests: jest.fn(), +}); + +describe('APIWithHooks', () => { + let api: jest.Mocked; + const urls = { + get: 'get', + post: 'post', + postForm: 'postForm', + delete: 'delete', + put: 'put', + }; + const someBodyData = {}; + const someGenericArgs = {}; + + beforeEach(() => { + jest.clearAllMocks(); + api = mockApi(); + }); + + describe('with beforeAnyRequest hook', () => { + const beforeMock = jest.fn(); + let apiWithHooks: APIWithHooks; + + beforeEach(() => { + apiWithHooks = new APIWithHooks(api, { + beforeAnyRequest: beforeMock, + }); + }); + + it('should trigger the hook on a get', async () => { + await apiWithHooks.get(urls.get, someGenericArgs); + + assertInvocationWasBefore(beforeMock, api.get as jest.Mock); + expect(api.get).toHaveBeenCalledWith(urls.get, someGenericArgs); + }); + + it('should trigger the hook on a post', async () => { + await apiWithHooks.post(urls.post, someBodyData, someGenericArgs); + + assertInvocationWasBefore(beforeMock, api.post as jest.Mock); + expect(api.post).toHaveBeenCalledWith(urls.post, someBodyData, someGenericArgs); + }); + + it('should trigger the hook on a postForm', async () => { + await apiWithHooks.postForm(urls.postForm, null, someGenericArgs); + + assertInvocationWasBefore(beforeMock, api.postForm as jest.Mock); + expect(api.postForm).toHaveBeenCalledWith(urls.postForm, null, someGenericArgs); + }); + + it('should trigger the hook on a put', async () => { + await apiWithHooks.put(urls.put, someBodyData, someGenericArgs); + + assertInvocationWasBefore(beforeMock, api.put as jest.Mock); + expect(api.put).toHaveBeenCalledWith(urls.put, someBodyData, someGenericArgs); + }); + + it('should trigger the hook on a delete', async () => { + await apiWithHooks.delete(urls.delete, someGenericArgs); + + assertInvocationWasBefore(beforeMock, api.delete as jest.Mock); + expect(api.delete).toHaveBeenCalledWith(urls.delete, someGenericArgs); + }); + }); + + describe('with afterAnySuccess hook', () => { + const afterMock = jest.fn(); + let apiWithHooks: APIWithHooks; + + beforeEach(() => { + apiWithHooks = new APIWithHooks(api, { + afterAnySuccess: afterMock, + }); + }); + + it('should trigger the hook on a get', async () => { + await apiWithHooks.get(urls.get, someGenericArgs); + + assertInvocationWasBefore(api.get as jest.Mock, afterMock); + expect(api.get).toHaveBeenCalledWith(urls.get, someGenericArgs); + }); + + it('should trigger the hook on a post', async () => { + await apiWithHooks.post(urls.post, someBodyData, someGenericArgs); + + assertInvocationWasBefore(api.post as jest.Mock, afterMock); + expect(api.post).toHaveBeenCalledWith(urls.post, someBodyData, someGenericArgs); + }); + + it('should trigger the hook on a postForm', async () => { + await apiWithHooks.postForm(urls.postForm, null, someGenericArgs); + + assertInvocationWasBefore(api.postForm as jest.Mock, afterMock); + expect(api.postForm).toHaveBeenCalledWith(urls.postForm, null, someGenericArgs); + }); + + it('should trigger the hook on a put', async () => { + await apiWithHooks.put(urls.put, someBodyData, someGenericArgs); + + assertInvocationWasBefore(api.put as jest.Mock, afterMock); + expect(api.put).toHaveBeenCalledWith(urls.put, someBodyData, someGenericArgs); + }); + + it('should trigger the hook on a delete', async () => { + await apiWithHooks.delete(urls.delete, someGenericArgs); + + assertInvocationWasBefore(api.delete as jest.Mock, afterMock); + expect(api.delete).toHaveBeenCalledWith(urls.delete, someGenericArgs); + }); + + it('should not trigger the hook on a get failure', async () => { + api.get.mockRejectedValue('ohno'); + + expect(apiWithHooks.get(urls.get, someGenericArgs)).rejects.toThrow('ohno'); + + expect(afterMock).not.toHaveBeenCalled(); + }); + }); + + describe('with afterAnyException hook', () => { + // Exception is handled + const afterExceptionMock = jest.fn().mockReturnValue(true); + const someError = 'ohno'; + let apiWithHooks: APIWithHooks; + + beforeEach(() => { + apiWithHooks = new APIWithHooks(api, { + afterAnyException: afterExceptionMock, + }); + }); + + it('should trigger the hook on a get exception', async () => { + api.get.mockRejectedValue(someError); + + await apiWithHooks.get(urls.get, someGenericArgs); + + assertInvocationWasBefore(api.get as jest.Mock, afterExceptionMock); + expect(afterExceptionMock).toHaveBeenCalledWith(urls.get, someGenericArgs, someError); + }); + + it('should not trigger the hook on a get success', async () => { + await apiWithHooks.get(urls.get, someGenericArgs); + + expect(afterExceptionMock).not.toHaveBeenCalled(); + }); + + it('should trigger the hook on a post exception', async () => { + api.post.mockRejectedValue(someError); + + await apiWithHooks.post(urls.post, someBodyData, someGenericArgs); + + assertInvocationWasBefore(api.post as jest.Mock, afterExceptionMock); + expect(afterExceptionMock).toHaveBeenCalledWith(urls.post, someGenericArgs, someError); + }); + + it('should not trigger the hook on a post success', async () => { + await apiWithHooks.post(urls.post, someBodyData, someGenericArgs); + + expect(afterExceptionMock).not.toHaveBeenCalled(); + }); + + it('should trigger the hook on a postForm exception', async () => { + api.postForm.mockRejectedValue(someError); + + await apiWithHooks.postForm(urls.postForm, null, someGenericArgs); + + assertInvocationWasBefore(api.postForm as jest.Mock, afterExceptionMock); + expect(afterExceptionMock).toHaveBeenCalledWith(urls.postForm, someGenericArgs, someError); + }); + + it('should not trigger the hook on a postForm success', async () => { + await apiWithHooks.postForm(urls.postForm, null, someGenericArgs); + + expect(afterExceptionMock).not.toHaveBeenCalled(); + }); + + it('should trigger the hook on a put exception', async () => { + api.put.mockRejectedValue(someError); + + await apiWithHooks.put(urls.put, someBodyData, someGenericArgs); + + assertInvocationWasBefore(api.put as jest.Mock, afterExceptionMock); + expect(afterExceptionMock).toHaveBeenCalledWith(urls.put, someGenericArgs, someError); + }); + + it('should not trigger the hook on a put success', async () => { + await apiWithHooks.put(urls.put, someBodyData, someGenericArgs); + + expect(afterExceptionMock).not.toHaveBeenCalled(); + }); + + it('should trigger the hook on a delete exception', async () => { + api.delete.mockRejectedValue(someError); + + await apiWithHooks.delete(urls.delete, someGenericArgs); + + assertInvocationWasBefore(api.delete as jest.Mock, afterExceptionMock); + expect(afterExceptionMock).toHaveBeenCalledWith(urls.delete, someGenericArgs, someError); + }); + + it('should not trigger the hook on a delete success', async () => { + await apiWithHooks.delete(urls.delete, someGenericArgs); + + expect(afterExceptionMock).not.toHaveBeenCalled(); + }); + }); + + describe('with all hooks', () => { + const beforeMock = jest.fn(); + const afterMock = jest.fn(); + const afterExceptionMock = jest.fn().mockReturnValue(true); + const someError = 'ohno'; + let apiWithHooks: APIWithHooks; + + beforeEach(() => { + apiWithHooks = new APIWithHooks(api, { + beforeAnyRequest: beforeMock, + afterAnySuccess: afterMock, + afterAnyException: afterExceptionMock, + }); + }); + + it('should only call beforeAnyRequest and afterAnySuccess on get success', async () => { + await apiWithHooks.get(urls.get, someGenericArgs); + + assertInvocationInOrder(beforeMock, api.get as jest.Mock, afterMock); + expect(afterExceptionMock).not.toHaveBeenCalled(); + }); + + it('should call only beforeAnyRequest and afterAnyException on get error', async () => { + api.get.mockRejectedValue(someError); + + await apiWithHooks.get(urls.get, someGenericArgs); + + assertInvocationInOrder(beforeMock, api.get as jest.Mock, afterExceptionMock); + expect(afterMock).not.toHaveBeenCalled(); + }); + }); + + const assertInvocationWasBefore = (expectedFirst: jest.Mock, expectedSecond: jest.Mock) => { + expect(expectedFirst).toHaveBeenCalled(); + expect(expectedSecond).toHaveBeenCalled(); + expect(expectedFirst.mock.invocationCallOrder[0]).toBeLessThan(expectedSecond.mock.invocationCallOrder[0]); + }; + + const assertInvocationInOrder = (...mocks: jest.Mock[]) => { + mocks.forEach((mock) => expect(mock).toHaveBeenCalled()); + mocks.slice(0, mocks.length - 1).forEach(({mock}, i) => { + expect(mock.invocationCallOrder[0]).toBeLessThan(mocks[i + 1].mock.invocationCallOrder[0]); + }); + }; +}); diff --git a/src/resources/PlatformResources.ts b/src/resources/PlatformResources.ts index 523cea4dc..401a1f3af 100644 --- a/src/resources/PlatformResources.ts +++ b/src/resources/PlatformResources.ts @@ -1,4 +1,4 @@ -import API from '../APICore'; +import {IAPI} from '../APICore'; import ApiKey from './ApiKeys/ApiKeys'; import AWS from './AWS/AWS'; import Catalog from './Catalogs/Catalog'; @@ -25,7 +25,7 @@ const resourcesMap: Array<{key: string; resource: typeof Resource}> = [ ]; class PlatformResources { - protected API: API; + protected API: IAPI; aws: AWS; catalog: Catalog; diff --git a/src/resources/Resource.ts b/src/resources/Resource.ts index 2d6fbc68a..f79a5204f 100644 --- a/src/resources/Resource.ts +++ b/src/resources/Resource.ts @@ -1,9 +1,16 @@ -import API from '../APICore'; +import {IAPI} from '../APICore'; +import {IAPIFeature} from '../features/APIFeature'; class Resource { static baseUrl: string; - constructor(protected api: API) {} + constructor(protected api: IAPI) {} + + withFeatures(...features: IAPIFeature[]): this { + const apiWithAllFeatures = features.reduce((acc, current) => current(acc), this.api); + const newInstance = Object.create(this) as this; + return newInstance.constructor(apiWithAllFeatures); + } protected buildPath(route: string, parameters: object): string { return route + this.convertObjectToQueryString(parameters); diff --git a/src/resources/tests/Resource.spec.ts b/src/resources/tests/Resource.spec.ts index dd4302df0..7f212d5d6 100644 --- a/src/resources/tests/Resource.spec.ts +++ b/src/resources/tests/Resource.spec.ts @@ -1,4 +1,4 @@ -import API from '../../APICore'; +import API, {IAPI} from '../../APICore'; import Resource from '../Resource'; jest.mock('../../APICore'); @@ -9,6 +9,9 @@ class ResourceFixture extends Resource { testBuildPath(route: string, params: object) { return super.buildPath(route, params); } + getSomething() { + return this.api.get('🏥'); + } } describe('Resource', () => { @@ -37,4 +40,46 @@ describe('Resource', () => { expect(resource.testBuildPath('/some/route', {a: 'b', c: 'd'})).toBe('/some/route?a=b&c=d'); }); }); + + describe('withFeatures', () => { + it('should call the feature with the initial API', () => { + const feature = jest.fn((resourceApi: IAPI) => resourceApi); + + resource.withFeatures(feature); + + expect(feature).toHaveBeenCalledWith(api); + }); + + it('should call all the features with the initial API', () => { + const firstFeature = jest.fn((resourceApi: IAPI) => resourceApi); + const secondFeature = jest.fn((resourceApi: IAPI) => resourceApi); + + resource.withFeatures(firstFeature, secondFeature); + + expect(firstFeature).toHaveBeenCalledWith(api); + expect(secondFeature).toHaveBeenCalledWith(api); + }); + + it('should call the new api when accessing the resources', async () => { + const apiThatShouldWrapTheInitialOne = new APIMock(); + const feature = jest.fn(() => apiThatShouldWrapTheInitialOne); + + const wrappedResource = resource.withFeatures(feature); + + await wrappedResource.getSomething(); + + expect(apiThatShouldWrapTheInitialOne.get).toHaveBeenCalled(); + }); + + it('should not call the new api when accessing the resource without the feature', async () => { + const apiThatShouldWrapTheInitialOne = new APIMock(); + const feature = jest.fn(() => apiThatShouldWrapTheInitialOne); + + resource.withFeatures(feature); + + await resource.getSomething(); + + expect(apiThatShouldWrapTheInitialOne.get).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/tests/PlatformClient.spec.ts b/src/tests/PlatformClient.spec.ts index f7038fe5d..ac5d3f056 100644 --- a/src/tests/PlatformClient.spec.ts +++ b/src/tests/PlatformClient.spec.ts @@ -1,5 +1,6 @@ import API from '../APICore'; import {PlatformClientOptions} from '../ConfigurationInterfaces'; +import {IAPIFeature} from '../features/APIFeature'; import PlatformClient from '../PlatformClient'; import PlatformResources from '../resources/PlatformResources'; @@ -128,4 +129,48 @@ describe('PlatformClient', () => { expect(abortGetRequestsSpy).toHaveBeenCalledTimes(1); }); + + describe('withFeatures', () => { + it('should create a copy of the client with the new feature', async () => { + const feature: IAPIFeature = jest.fn((api) => api); + const client = new PlatformClient(baseOptions); + + const clientWithFeature = client.withFeatures(feature); + + expect(clientWithFeature).not.toBe(client); + }); + + it('should execute the feature', async () => { + const feature: IAPIFeature = jest.fn((api) => api); + const client = new PlatformClient(baseOptions); + + client.withFeatures(feature); + + expect(feature).toHaveBeenCalled(); + }); + + it('should call the new api when accessing the resource with the feature', async () => { + const apiThatShouldWrapTheInitialOne = new APIMock(); + const feature = jest.fn(() => apiThatShouldWrapTheInitialOne); + + const client = new PlatformClient(baseOptions); + + await client.withFeatures(feature).catalog.get('someId'); + + expect(apiThatShouldWrapTheInitialOne.get).toHaveBeenCalled(); + }); + + it('should not call the new api when accessing the resource without the feature', async () => { + const apiThatShouldWrapTheInitialOne = new APIMock(); + const feature = jest.fn(() => apiThatShouldWrapTheInitialOne); + + const client = new PlatformClient(baseOptions); + + client.withFeatures(feature); + + await client.catalog.get('someId'); + + expect(apiThatShouldWrapTheInitialOne.get).not.toHaveBeenCalled(); + }); + }); }); From b9b594cc0e4b4fc7194e7f9af662f47886089664 Mon Sep 17 00:00:00 2001 From: Francois Lachance-Guillemette Date: Mon, 16 Dec 2019 11:47:34 -0500 Subject: [PATCH 2/2] feat(apihooks): applied round of reviews * Use "new type" instead of Object.create * BeforeAny & AfterAny => Before & After --- src/PlatformClient.ts | 6 +++--- src/features/APIWithHooks.ts | 12 ++++++------ src/features/tests/APIWithHooks.spec.ts | 22 +++++++++++----------- src/resources/Resource.ts | 4 ++-- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/PlatformClient.ts b/src/PlatformClient.ts index 776054dd7..40dee3d9c 100644 --- a/src/PlatformClient.ts +++ b/src/PlatformClient.ts @@ -47,11 +47,11 @@ export class PlatformClient extends PlatformResources { } withFeatures(...features: IAPIFeature[]): this { - const newInstance = Object.create(this) as this; - return newInstance.constructor({ + const type = this.constructor as typeof PlatformClient; + return new type({ ...this.options, apiFeatures: [...(this.options.apiFeatures || []), ...features], - } as PlatformClientOptions); + } as PlatformClientOptions) as this; } async initialize() { diff --git a/src/features/APIWithHooks.ts b/src/features/APIWithHooks.ts index 918990af1..fcf4c7301 100644 --- a/src/features/APIWithHooks.ts +++ b/src/features/APIWithHooks.ts @@ -1,9 +1,9 @@ import {IAPI} from '../APICore'; export interface IAPIHooks { - beforeAnyRequest?: (url: string, args: RequestInit) => void; - afterAnySuccess?: (url: string, args: RequestInit, response: T) => T; - afterAnyException?: (url: string, args: RequestInit, exception: Error) => boolean; + beforeRequest?: (url: string, args: RequestInit) => void; + afterSuccess?: (url: string, args: RequestInit, response: T) => T; + afterException?: (url: string, args: RequestInit, exception: Error) => boolean; } export class APIWithHooks implements IAPI { @@ -46,14 +46,14 @@ export class APIWithHooks implements IAPI { } private async wrapInGenericHandler(url: string, args: RequestInit, request: () => Promise) { - this.hooks.beforeAnyRequest?.(url, args); + this.hooks.beforeRequest?.(url, args); try { const response = await request(); - this.hooks.afterAnySuccess?.(url, args, response); + this.hooks.afterSuccess?.(url, args, response); return response; } catch (exception) { - if (!this.hooks.afterAnyException?.(url, args, exception)) { + if (!this.hooks.afterException?.(url, args, exception)) { throw exception; } } diff --git a/src/features/tests/APIWithHooks.spec.ts b/src/features/tests/APIWithHooks.spec.ts index 82642e59d..976f2618e 100644 --- a/src/features/tests/APIWithHooks.spec.ts +++ b/src/features/tests/APIWithHooks.spec.ts @@ -30,13 +30,13 @@ describe('APIWithHooks', () => { api = mockApi(); }); - describe('with beforeAnyRequest hook', () => { + describe('with beforeRequest hook', () => { const beforeMock = jest.fn(); let apiWithHooks: APIWithHooks; beforeEach(() => { apiWithHooks = new APIWithHooks(api, { - beforeAnyRequest: beforeMock, + beforeRequest: beforeMock, }); }); @@ -76,13 +76,13 @@ describe('APIWithHooks', () => { }); }); - describe('with afterAnySuccess hook', () => { + describe('with afterSuccess hook', () => { const afterMock = jest.fn(); let apiWithHooks: APIWithHooks; beforeEach(() => { apiWithHooks = new APIWithHooks(api, { - afterAnySuccess: afterMock, + afterSuccess: afterMock, }); }); @@ -130,7 +130,7 @@ describe('APIWithHooks', () => { }); }); - describe('with afterAnyException hook', () => { + describe('with afterException hook', () => { // Exception is handled const afterExceptionMock = jest.fn().mockReturnValue(true); const someError = 'ohno'; @@ -138,7 +138,7 @@ describe('APIWithHooks', () => { beforeEach(() => { apiWithHooks = new APIWithHooks(api, { - afterAnyException: afterExceptionMock, + afterException: afterExceptionMock, }); }); @@ -227,20 +227,20 @@ describe('APIWithHooks', () => { beforeEach(() => { apiWithHooks = new APIWithHooks(api, { - beforeAnyRequest: beforeMock, - afterAnySuccess: afterMock, - afterAnyException: afterExceptionMock, + beforeRequest: beforeMock, + afterSuccess: afterMock, + afterException: afterExceptionMock, }); }); - it('should only call beforeAnyRequest and afterAnySuccess on get success', async () => { + it('should only call beforeRequest and afterSuccess on get success', async () => { await apiWithHooks.get(urls.get, someGenericArgs); assertInvocationInOrder(beforeMock, api.get as jest.Mock, afterMock); expect(afterExceptionMock).not.toHaveBeenCalled(); }); - it('should call only beforeAnyRequest and afterAnyException on get error', async () => { + it('should call only beforeRequest and afterException on get error', async () => { api.get.mockRejectedValue(someError); await apiWithHooks.get(urls.get, someGenericArgs); diff --git a/src/resources/Resource.ts b/src/resources/Resource.ts index f79a5204f..7d425f960 100644 --- a/src/resources/Resource.ts +++ b/src/resources/Resource.ts @@ -8,8 +8,8 @@ class Resource { withFeatures(...features: IAPIFeature[]): this { const apiWithAllFeatures = features.reduce((acc, current) => current(acc), this.api); - const newInstance = Object.create(this) as this; - return newInstance.constructor(apiWithAllFeatures); + const type = this.constructor as typeof Resource; + return new type(apiWithAllFeatures) as this; } protected buildPath(route: string, parameters: object): string {