Skip to content

Commit

Permalink
Merge pull request #44 from coveo/experimental-api-features-hoc
Browse files Browse the repository at this point in the history
API Features for Client and Resources
  • Loading branch information
francoislg authored Jan 14, 2020
2 parents 8ae4b11 + b9b594c commit 8bb9ab4
Show file tree
Hide file tree
Showing 10 changed files with 451 additions and 8 deletions.
5 changes: 4 additions & 1 deletion src/APICore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/ConfigurationInterfaces.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {IAPIFeature} from './features/APIFeature';
import {ResponseHandler} from './handlers/ResponseHandlerInterfaces';

export interface APIConfiguration {
Expand All @@ -9,6 +10,7 @@ export interface APIConfiguration {
accessTokenRetriever: () => string;
host?: string;
responseHandlers?: ResponseHandler[];
apiFeatures?: IAPIFeature[];
}

export interface PlatformClientOptions extends APIConfiguration {
Expand Down
16 changes: 14 additions & 2 deletions src/PlatformClient.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 type = this.constructor as typeof PlatformClient;
return new type({
...this.options,
apiFeatures: [...(this.options.apiFeatures || []), ...features],
} as PlatformClientOptions) as this;
}

async initialize() {
try {
this.tokenInfo = await this.checkToken();
Expand Down
3 changes: 3 additions & 0 deletions src/features/APIFeature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {IAPI} from '../APICore';

export type IAPIFeature = (api: IAPI) => IAPI;
61 changes: 61 additions & 0 deletions src/features/APIWithHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {IAPI} from '../APICore';

export interface IAPIHooks {
beforeRequest?: (url: string, args: RequestInit) => void;
afterSuccess?: <T>(url: string, args: RequestInit, response: T) => T;
afterException?: (url: string, args: RequestInit, exception: Error) => boolean;
}

export class APIWithHooks<TAPI extends IAPI = IAPI> implements IAPI {
constructor(private api: TAPI, private hooks: IAPIHooks) {}

get organizationId() {
return this.api.organizationId;
}

async get<T = {}>(url: string, args: RequestInit = {method: 'get'}): Promise<T> {
return this.wrapInGenericHandler(url, args, () => this.api.get<T>(url, args));
}

async post<T = {}>(
url: string,
body: any = {},
args: RequestInit = {method: 'post', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'}}
): Promise<T> {
return this.wrapInGenericHandler(url, args, () => this.api.post<T>(url, body, args));
}

async postForm<T = {}>(url: string, form: FormData, args: RequestInit = {method: 'post', body: form}): Promise<T> {
return this.wrapInGenericHandler(url, args, () => this.api.postForm<T>(url, form, args));
}

async put<T = {}>(
url: string,
body: any,
args: RequestInit = {method: 'put', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'}}
): Promise<T> {
return this.wrapInGenericHandler(url, args, () => this.api.put<T>(url, body, args));
}

async delete<T = {}>(url: string, args: RequestInit = {method: 'delete'}): Promise<T> {
return this.wrapInGenericHandler(url, args, () => this.api.delete<T>(url, args));
}

abortGetRequests(): void {
this.api.abortGetRequests();
}

private async wrapInGenericHandler<T>(url: string, args: RequestInit, request: () => Promise<T>) {
this.hooks.beforeRequest?.(url, args);

try {
const response = await request();
this.hooks.afterSuccess?.<T>(url, args, response);
return response;
} catch (exception) {
if (!this.hooks.afterException?.(url, args, exception)) {
throw exception;
}
}
}
}
265 changes: 265 additions & 0 deletions src/features/tests/APIWithHooks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import {IAPI} from '../../APICore';
import {APIWithHooks} from '../APIWithHooks';

jest.mock('../../APICore');

const mockApi = (): jest.Mocked<IAPI> => ({
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<IAPI>;
const urls = {
get: 'get',
post: 'post',
postForm: 'postForm',
delete: 'delete',
put: 'put',
};
const someBodyData = {};
const someGenericArgs = {};

beforeEach(() => {
jest.clearAllMocks();
api = mockApi();
});

describe('with beforeRequest hook', () => {
const beforeMock = jest.fn();
let apiWithHooks: APIWithHooks;

beforeEach(() => {
apiWithHooks = new APIWithHooks(api, {
beforeRequest: 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 afterSuccess hook', () => {
const afterMock = jest.fn();
let apiWithHooks: APIWithHooks;

beforeEach(() => {
apiWithHooks = new APIWithHooks(api, {
afterSuccess: 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 afterException hook', () => {
// Exception is handled
const afterExceptionMock = jest.fn().mockReturnValue(true);
const someError = 'ohno';
let apiWithHooks: APIWithHooks;

beforeEach(() => {
apiWithHooks = new APIWithHooks(api, {
afterException: 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, {
beforeRequest: beforeMock,
afterSuccess: afterMock,
afterException: afterExceptionMock,
});
});

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 beforeRequest and afterException 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]);
});
};
});
Loading

0 comments on commit 8bb9ab4

Please sign in to comment.