Skip to content

Commit

Permalink
use fetch as a configuration class
Browse files Browse the repository at this point in the history
  • Loading branch information
rrivem committed Sep 1, 2018
1 parent b2b192a commit b10c940
Show file tree
Hide file tree
Showing 4 changed files with 273 additions and 29 deletions.
6 changes: 3 additions & 3 deletions client/components/app/App.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { getJson } from 'utils/fetch';
import { getJson, run } from 'utils/fetch';
import links from '../../routes/links';
import Clickable from '../clickable';
import styles from './app.scss';
Expand All @@ -18,13 +18,13 @@ class App extends React.Component {
}

componentDidMount() {
getJson(links.api.sample)
run(getJson(links.api.sample))
.then(sample => this.setState({ sample }))
.catch(error => this.setState({ error }));
}

handleClick() {
return getJson(links.chucknorris).then(response =>
return run(getJson(links.chucknorris)).then(response =>
this.setState({
jokes: [response.value, ...this.state.jokes]
})
Expand Down
19 changes: 11 additions & 8 deletions client/components/app/App.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import { shallow, mount } from 'enzyme';

import { getJson } from 'utils/fetch';
import { getJson, run } from 'utils/fetch';
import Clickable from '../clickable';
import links from '../../routes/links';
import App from './App';

jest.mock('utils/fetch', () => ({
getJson: jest.fn(url => new Promise((resolve, reject) => (url ? resolve({ value: 'another joke' }) : reject())))
getJson: url => ({ url }),
run: jest.fn(
config => new Promise((resolve, reject) => (config.url ? resolve({ value: 'another joke' }) : reject()))
)
}));

describe('<App/>', () => {
Expand All @@ -23,12 +26,12 @@ describe('App - button onClick', () => {
const app = shallow(<App />);

beforeEach(() => {
getJson.mockImplementation(() => Promise.resolve({ value: 'another joke' }));
run.mockImplementation(() => Promise.resolve({ value: 'another joke' }));
app.find('button').simulate('click');
});

it('should call ChuckNorris api', () => {
expect(getJson).toBeCalledWith(links.chucknorris);
expect(run).toBeCalledWith(getJson(links.chucknorris));
});

it('should keep previous jokes', () => {
Expand All @@ -53,11 +56,11 @@ describe('App - button onClick', () => {
describe('App - mounting', () => {
describe('server request succeeded', () => {
const sample = [{ id: 1 }, { id: 2 }];
getJson.mockImplementation(() => Promise.resolve(sample));
run.mockImplementation(() => Promise.resolve(sample));
const tree = mount(<App />);

it('should call getJson', () => {
expect(getJson).toBeCalledWith(links.api.sample);
it('should call run', () => {
expect(run).toBeCalledWith(getJson(links.api.sample));
});

it('should update state.sample', () => {
Expand All @@ -66,7 +69,7 @@ describe('App - mounting', () => {
});

describe('server request error', () => {
getJson.mockImplementation(() => Promise.reject({ code: 500 }));
run.mockImplementation(() => Promise.reject({ code: 500 }));
const app = mount(<App />);

it('should update state.error', () => {
Expand Down
216 changes: 202 additions & 14 deletions client/utils/__tests__/fetch.spec.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,216 @@
import mockResponse from '../__mocks__/fetch-response.mock';
import { getJson } from '../fetch';
import { getJson, postJson, fetchJson, Fetch, run, responseAsJson, handleError } from '../fetch';

const _fetch = window.fetch;
describe('window.fetch', () => {
it('should define polyfill', () => {
expect(_fetch.polyfill).toBe(true);
});
});

describe('fetch', () => {
it('should call then part when response was successful', () => {
const RESPONSE = { joke: 'kcuhC' };
window.fetch = jest.fn(() => Promise.resolve(mockResponse(200, null, JSON.stringify(RESPONSE))));
describe('responseAsJson', () => {
it('should call json method from response', () => {
const response = { json: jest.fn() };
responseAsJson(response);
expect(response.json).toBeCalled();
});
});

return getJson('//url').then(response => {
expect(response).toEqual(RESPONSE);
describe('handleError', () => {
describe('when response is ok', () => {
const response = { ok: true };
it('should return response object', () => {
expect(handleError(response)).toBe(response);
});
});
describe('when response is not ok', () => {
const response = { ok: false, status: 500, statusText: 'Internal Server Error' };
const call = () => handleError(response);
it('should throw error with statusText as message, status and response', () => {
const expected = new Error('Internal Server Error');
expected.status = 500;
expected.response = response;
expect(call).toThrow(expected);
});
});
});

it('should call catch part when response was not successful', () => {
const RESPONSE = { status: 404, statusText: '404 Error' };
window.fetch = jest.fn(() => Promise.resolve(mockResponse(404, '404 Error', JSON.stringify(RESPONSE))));
describe('run', () => {
it('should call window.fetch with url and request', done => {
const response = { data: 'calculator' };
const mockedResponse = mockResponse(200, null, JSON.stringify(response));
window.fetch = jest.fn(() => Promise.resolve(mockedResponse));
const config = {
url: '/api/calculator',
request: {
method: 'GET'
}
};
run(config)
.then(() => expect(window.fetch).toBeCalledWith(config.url, config.request))
.then(done);
});
it('should call errorHandler with mocked response', done => {
const response = { status: 500, statusText: 'Internal Server Error' };
const mockedResponse = mockResponse(500, null, JSON.stringify(response));
window.fetch = jest.fn(() => Promise.resolve(mockedResponse));
const config = {
errorHandler: jest.fn()
};
run(config)
.then(() => expect(config.errorHandler).toBeCalledWith(mockedResponse))
.then(done);
});
it('should call responseHandler with mocked response', done => {
const response = { data: 'calculator' };
const mockedResponse = mockResponse(200, null, JSON.stringify(response));
window.fetch = jest.fn(() => Promise.resolve(mockedResponse));
const config = {
responseHandler: jest.fn()
};
run(config)
.then(() => expect(config.responseHandler).toBeCalledWith(mockedResponse))
.then(done);
});
afterEach(() => (window.fetch = _fetch));
});

return getJson('//url').catch(error => {
expect(error.status).toEqual(RESPONSE.status);
describe('Fetch', () => {
const url = '/api/calculator';
let fetch, result;
const checkResult = () => expect(result).toBe(fetch);
beforeEach(() => (fetch = new Fetch(url)));
describe('constructor', () => {
it('should set the url', () => {
expect(fetch.url).toEqual(url);
});
it('should set the request just with empty headers', () => {
const expected = { headers: {} };
expect(fetch.request).toEqual(expected);
});
it('should set the errorHandler with handleError', () => {
expect(fetch.errorHandler).toBe(handleError);
});
it('should set the responseHandler with identity func', () => {
expect(fetch.responseHandler(url)).toBe(url);
});
});
describe('withMethod', () => {
const method = 'GET';
beforeEach(() => (result = fetch.withMethod(method)));
it('should set request method prop with argument', () => {
expect(fetch.request.method).toEqual(method);
});
it('should return fetch instance', checkResult);
});
describe('withHeader', () => {
const name = 'Content-Type';
const value = 'application/json';
beforeEach(() => (result = fetch.withHeader(name, value)));
it('should add header to request headers prop', () => {
const expected = { [name]: value };
expect(fetch.request.headers).toMatchObject(expected);
});
it('should return fetch instance', checkResult);
});
describe('withHeaders', () => {
const headers = {
'Content-Type': 'application/json',
'Content-Length': 1232
};
beforeEach(() => (result = fetch.withHeaders(headers)));
it('should add all headers to request headers prop', () => {
const expected = headers;
expect(fetch.request.headers).toMatchObject(expected);
});
it('should return fetch instance', checkResult);
});
describe('withBasicAuthentication', () => {
const username = 'test@rri.com';
const password = 'test';
beforeEach(() => (result = fetch.withBasicAuthentication(username, password)));
it('should add header Authorization with Basic credentials in base64', () => {
const credentials = 'test@rri.com:test';
const expected = { Authorization: `Basic ${btoa(credentials)}` };
expect(fetch.request.headers).toMatchObject(expected);
});
it('should return fetch instance', checkResult);
});
describe('withJWT', () => {
const jwt = 'jwt-token';
beforeEach(() => (result = fetch.withJWT(jwt)));
it('should add header Authorization with Bearer token', () => {
const expected = { Authorization: `Bearer ${jwt}` };
expect(fetch.request.headers).toMatchObject(expected);
});
it('should return fetch instance', checkResult);
});
describe('withBody', () => {
const body = { data: 'calculator' };
beforeEach(() => (result = fetch.withBody(body)));
it('should set request body prop with stringified argument', () => {
const expected = { body: JSON.stringify(body) };
expect(fetch.request).toMatchObject(expected);
});
it('should add header Content-Type with application/json', () => {
const expected = { 'Content-Type': 'application/json' };
expect(fetch.request.headers).toMatchObject(expected);
});
it('should return fetch instance', checkResult);
});
describe('withRawBody', () => {
const body = 'This is a raw text body';
const contentType = 'text/plain';
beforeEach(() => (result = fetch.withRawBody(body, contentType)));
it('should set request body prop with unmodified body', () => {
const expected = { body: body };
expect(fetch.request).toMatchObject(expected);
});
it('should add header Content-Type with contentType', () => {
const expected = { 'Content-Type': contentType };
expect(fetch.request.headers).toMatchObject(expected);
});
it('should return fetch instance', checkResult);
});
describe('parseResponseAsJson', () => {
beforeEach(() => (result = fetch.parseResponseAsJson()));
it('should set responseHandler with responseAsJson', () => {
expect(fetch.responseHandler).toBe(responseAsJson);
});
it('should add header Accept with application/json', () => {
const expected = { Accept: 'application/json' };
expect(fetch.request.headers).toMatchObject(expected);
});
it('should return fetch instance', checkResult);
});
});

it('has window.fetch polyfill', () => {
expect(_fetch.polyfill).toBe(true);
describe('fetchJson', () => {
const url = '/api/calculator';
const fetch = fetchJson(url);
it('should return fetch with url', () => {
expect(fetch.url).toBe(url);
});
it('should have called parseResponseAsJson', () => {
const expected = new Fetch(url).parseResponseAsJson();
expect(fetch).toEqual(expected);
});
});

describe('getJson', () => {
it('should have called withMethod with GET', () => {
const url = '/api/calculator';
const fetch = getJson(url);
const expected = fetchJson(url).withMethod('GET');
expect(fetch).toEqual(expected);
});
});

describe('postJson', () => {
it('should have called withMethod with POST', () => {
const url = '/api/calculator';
const fetch = postJson(url);
const expected = fetchJson(url).withMethod('POST');
expect(fetch).toEqual(expected);
});
});
61 changes: 57 additions & 4 deletions client/utils/fetch.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'es6-promise/auto';
import 'whatwg-fetch';

const _errorHandle = response => {
export const handleError = response => {
if (response.ok) {
return response;
}
Expand All @@ -13,8 +13,61 @@ const _errorHandle = response => {
throw error;
};

const _parseJson = response => response.json();
export const handleResponse = x => x;
export const responseAsJson = response => response.json();

export const getJson = url => fetch(url).then(_errorHandle).then(_parseJson);
export class Fetch {
constructor(url) {
this.url = url;
this.request = {
headers: {}
};
this.errorHandler = handleError;
this.responseHandler = handleResponse;
}
withMethod(method) {
this.request.method = method;
return this;
}
withHeader(name, value) {
this.request.headers[name] = value;
return this;
}
withHeaders(headers) {
Object.keys(headers).forEach(name => this.withHeader(name, headers[name]));
return this;
}
withBasicAuthentication(username, password) {
const credentials = btoa(`${username}:${password}`);
return this.withHeader('Authorization', `Basic ${credentials}`);
}
withJWT(jwt) {
return this.withHeader('Authorization', `Bearer ${jwt}`);
}
withBody(body) {
this.request.body = JSON.stringify(body);
return this.withHeader('Content-Type', 'application/json');
}
withRawBody(body, contentType) {
this.request.body = body;
return this.withHeader('Content-Type', contentType);
}
parseResponseAsJson() {
this.responseHandler = responseAsJson;
return this.withHeader('Accept', 'application/json');
}
}

export const run = ({ url, request, errorHandler, responseHandler }) =>
fetch(url, request)
.then(errorHandler)
.then(responseHandler);

export const fetchJson = url => new Fetch(url).parseResponseAsJson();

export const getJson = url => fetchJson(url).withMethod('GET');
export const postJson = url => fetchJson(url).withMethod('POST');
export const putJson = url => fetchJson(url).withMethod('PUT');
export const deleteJson = url => fetchJson(url).withMethod('DELETE');

export default { getJson };
export const post = url => new Fetch(url).withMethod('POST');

0 comments on commit b10c940

Please sign in to comment.