Skip to content

Commit

Permalink
feat: custom response handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
gdostie committed Sep 25, 2019
1 parent 086379d commit 4626628
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 43 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,30 @@ This project is built using TypeScript and automatically generates relevant type
| `organizationId` | yes | undefined | The unique identifier of the target organization. |
| `environment` | optional | `'production'` | The target environment. If one of following: `'development'`, `'staging'`, `'production'`, `'hipaa'`; automatically targets the associated host. |
| `host` | optional | `'https://platform.cloud.coveo.com'` | The target host. Useful to target local hosts when testing. |
| `responseHandlers` | optional | [] | Custom server response handlers. See [error handling section](#error-handling) for detailed explanation. |

### Error handling

Each request made by the `platform-client`, once resolved or rejected, gets processed by one (and only one) of the response handlers. Some very basic response handlers are used by default, but you can override their behavior by specifying your own in the `responseHandlers` [configuration option](#configuration-option). The order in which they are specified defines their priority. Meaning that the first handler of the array that can process the response is used to do so.

A response handler is defined as such:

```ts
interface IRestResponseHandler {
canProcess(response: Response): boolean; // whether the handler should be used to process the response
process<T>(response: Response): Promise<IRestResponse<T>>; // defines how the handler processes the response
}
```

Example

```ts
const MySuccessResponseHandler: IRestResponseHandler = {
canProcess: (response: Response): boolean => response.ok;
process: async <T>(response: Response): Promise<IRestResponse<T>> => {
const data = await response.json();
console.log(data);
return data;
};
}
```
33 changes: 24 additions & 9 deletions src/APICore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {IRestResponse} from './handlers/HandlerConstants';
import {Handlers} from './handlers/Handlers';
import {Handlers, IRestResponseHandler} from './handlers/Handlers';

function removeEmptyEntries(obj) {
return Object.keys(obj).reduce((memo, key) => {
Expand All @@ -17,10 +17,17 @@ function convertToQueryString(parameters: any) {
return parameters ? `?${new URLSearchParams(Object.entries(removeEmptyEntries(parameters))).toString()}` : '';
}

export interface APIConfiguration {
organizationId: string;
accessTokenRetriever: () => string;
host?: string;
responseHandlers?: IRestResponseHandler[];
}

export default class API {
static orgPlaceholder = '{organizationName}';

constructor(private host: string, private orgId: string, private accessTokenRetriever: () => string) {}
constructor(private config: APIConfiguration) {}

async get<T>(url: string, queryParams?: any, args: RequestInit = {method: 'get'}): Promise<IRestResponse<T>> {
return await this.request<T>(url + convertToQueryString(queryParams), args);
Expand All @@ -47,22 +54,30 @@ export default class API {
}

private handleResponse<T>(response: Response) {
const canProcess = Handlers.filter((handler) => handler.canProcess(response));
return canProcess[0].process<T>(response);
const responseHandler = this.handlers.filter((handler) => handler.canProcess(response))[0];
return responseHandler.process<T>(response);
}

private get handlers(): IRestResponseHandler[] {
const customHandlers = this.config.responseHandlers || [];
return [...customHandlers, ...Handlers];
}

private getUrlFromRoute(route: string): string {
return `${this.config.host}${route}`.replace(API.orgPlaceholder, this.config.organizationId);
}

private async request<T>(urlWithOrg: string, args: RequestInit): Promise<IRestResponse<T>> {
private async request<T>(route: string, args: RequestInit): Promise<IRestResponse<T>> {
const init: RequestInit = {
...args,
headers: {
'Content-Type': 'application/json',
authorization: `Bearer ${this.accessTokenRetriever()}`,
authorization: `Bearer ${this.config.accessTokenRetriever()}`,
...(args.headers || {}),
},
};
const url = `${this.host}${urlWithOrg}`.replace(API.orgPlaceholder, this.orgId);

const response = await fetch(url, init);
return this.handleResponse(response);
const response = await fetch(this.getUrlFromRoute(route), init);
return this.handleResponse<T>(response);
}
}
25 changes: 17 additions & 8 deletions src/CoveoPlatform.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import API from './APICore';
import API, {APIConfiguration} from './APICore';
import {CoveoPlatformResources, Resources} from './resources/Resources';

export interface CoveoPlatformOptions {
organizationId: string;
accessTokenRetriever: () => string;
export interface CoveoPlatformOptions extends APIConfiguration {
environment?: string;
host?: string;
}

export default class CoveoPlatform extends CoveoPlatformResources {
Expand All @@ -23,6 +20,7 @@ export default class CoveoPlatform extends CoveoPlatformResources {
};
static defaultOptions: Partial<CoveoPlatformOptions> = {
environment: CoveoPlatform.Environments.prod,
responseHandlers: [],
};

private options: CoveoPlatformOptions;
Expand All @@ -37,12 +35,11 @@ export default class CoveoPlatform extends CoveoPlatformResources {
...options,
};

const host = this.options.host || CoveoPlatform.Hosts[this.options.environment];
if (!host) {
if (!this.host) {
throw new Error(`CoveoPlatform's host is undefined.`);
}

this.API = new API(host, this.options.organizationId, this.options.accessTokenRetriever);
this.API = new API(this.apiConfiguration);
Resources.registerAll(this, this.API);
}

Expand All @@ -54,6 +51,18 @@ export default class CoveoPlatform extends CoveoPlatformResources {
}
}

private get apiConfiguration(): APIConfiguration {
const {environment, ...apiConfig} = this.options;
return {
...apiConfig,
host: this.host,
};
}

private get host(): string {
return this.options.host || CoveoPlatform.Hosts[this.options.environment];
}

private async checkToken() {
return this.API.post('/oauth/check_token', {token: this.options.accessTokenRetriever()});
}
Expand Down
36 changes: 24 additions & 12 deletions src/tests/APICore.spec.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import API from '../APICore';
import API, {APIConfiguration} from '../APICore';
import {IRestResponseHandler} from '../handlers/Handlers';
import {UnauthorizedResponseError} from '../handlers/UnauthorizedResponseHandler';

describe('APICore', () => {
const testOptions = {
const testConfig: APIConfiguration = {
host: 'https://some.url/',
org: 'some-org-id',
token: 'my-token',
organizationId: 'some-org-id',
accessTokenRetriever: jest.fn(() => 'my-token'),
};
const testData = {
route: 'rest/resource',
response: {nuggets: 12345},
body: {q: 'how many nuggets'},
};
const getTestToken = jest.fn(() => testOptions.token);
const api = new API(testOptions.host, testOptions.org, getTestToken);
const api = new API(testConfig);

beforeEach(() => {
global.fetch.resetMocks();
jest.clearAllMocks();
});

describe('get', () => {
Expand All @@ -27,7 +27,7 @@ describe('APICore', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0];

expect(url).toBe(`${testOptions.host}${testData.route}`);
expect(url).toBe(`${testConfig.host}${testData.route}`);
expect(options.method).toBe('get');
expect(response).toEqual(testData.response);
});
Expand All @@ -37,7 +37,7 @@ describe('APICore', () => {
await api.get(testData.route, {a: 'b', c: 'd'});
const [url] = fetchMock.mock.calls[0];

expect(url).toBe(`${testOptions.host}${testData.route}?a=b&c=d`);
expect(url).toBe(`${testConfig.host}${testData.route}?a=b&c=d`);
});

test('failed request', async () => {
Expand All @@ -60,7 +60,7 @@ describe('APICore', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0];

expect(url).toBe(`${testOptions.host}${testData.route}`);
expect(url).toBe(`${testConfig.host}${testData.route}`);
expect(options.method).toBe('post');
expect(options.body).toBe(JSON.stringify(testData.body));
expect(response).toEqual(testData.response);
Expand All @@ -75,7 +75,7 @@ describe('APICore', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0];

expect(url).toBe(`${testOptions.host}${testData.route}`);
expect(url).toBe(`${testConfig.host}${testData.route}`);
expect(options.method).toBe('put');
expect(options.body).toBe(JSON.stringify(testData.body));
expect(response).toEqual(testData.response);
Expand All @@ -90,9 +90,21 @@ describe('APICore', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0];

expect(url).toBe(`${testOptions.host}${testData.route}`);
expect(url).toBe(`${testConfig.host}${testData.route}`);
expect(options.method).toBe('delete');
expect(response).toEqual(testData.response);
});
});

it('should give priority to custom response handlers when specified', async () => {
global.fetch.mockResponseOnce(JSON.stringify(testData.response));
const CustomResponseHandler: IRestResponseHandler = {
canProcess: (response: Response): boolean => response.ok,
process: jest.fn(),
};
const apiWithCustomResponseHandler = new API({...testConfig, responseHandlers: [CustomResponseHandler]});
await apiWithCustomResponseHandler.get('some/resource');

expect(CustomResponseHandler.process).toHaveBeenCalledTimes(1);
});
});
37 changes: 23 additions & 14 deletions src/tests/CoveoPlatform.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,13 @@ jest.mock('../APICore');
const APIMock: jest.Mock<API> = API as any;

describe('CoveoPlatform', () => {
const tokenRetriever = jest.fn(() => 'my-token');

const baseOptions: CoveoPlatformOptions = {
accessTokenRetriever: tokenRetriever,
accessTokenRetriever: jest.fn(() => 'my-token'),
organizationId: 'some-org',
};

beforeEach(() => {
global.fetch.resetMocks();
tokenRetriever.mockClear();
jest.clearAllMocks();
APIMock.mockClear();
});

Expand All @@ -34,35 +31,47 @@ describe('CoveoPlatform', () => {
test('the API uses the production host if no environment option is provided', () => {
new CoveoPlatform(baseOptions);
expect(APIMock).toHaveBeenCalledWith(
CoveoPlatform.Hosts[CoveoPlatform.Environments.prod],
expect.anything(),
expect.anything()
expect.objectContaining({
host: CoveoPlatform.Hosts[CoveoPlatform.Environments.prod],
})
);
});

test('the API uses the host associated with the environment specified in the options', () => {
new CoveoPlatform({...baseOptions, environment: CoveoPlatform.Environments.dev});
expect(APIMock).toHaveBeenCalledWith(
CoveoPlatform.Hosts[CoveoPlatform.Environments.dev],
expect.anything(),
expect.anything()
expect.objectContaining({
host: CoveoPlatform.Hosts[CoveoPlatform.Environments.dev],
})
);
});

test('the API uses the custom host specified in the options if any', () => {
const myCustomHost = 'localhost:9999/my-api-running-locally';
new CoveoPlatform({...baseOptions, host: myCustomHost});
expect(APIMock).toHaveBeenCalledWith(myCustomHost, expect.anything(), expect.anything());
expect(APIMock).toHaveBeenCalledWith(
expect.objectContaining({
host: myCustomHost,
})
);
});

test('the API uses the organization id specified in the options', () => {
new CoveoPlatform(baseOptions);
expect(APIMock).toHaveBeenCalledWith(expect.anything(), baseOptions.organizationId, expect.anything());
expect(APIMock).toHaveBeenCalledWith(
expect.objectContaining({
organizationId: baseOptions.organizationId,
})
);
});

test('the API uses the accessTokenRetriever function specified in the options', () => {
new CoveoPlatform(baseOptions);
expect(APIMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), tokenRetriever);
expect(APIMock).toHaveBeenCalledWith(
expect.objectContaining({
accessTokenRetriever: baseOptions.accessTokenRetriever,
})
);
});

it('should register all the resources on the platform instance', () => {
Expand Down

0 comments on commit 4626628

Please sign in to comment.