-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #44 from coveo/experimental-api-features-hoc
API Features for Client and Resources
- Loading branch information
Showing
10 changed files
with
451 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import {IAPI} from '../APICore'; | ||
|
||
export type IAPIFeature = (api: IAPI) => IAPI; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | ||
}); | ||
}; | ||
}); |
Oops, something went wrong.