Skip to content

Commit 2751796

Browse files
authored
Merge pull request #1 from prezly/feature/api
Feature - API
2 parents d70d36e + 9788564 commit 2751796

File tree

15 files changed

+4270
-57
lines changed

15 files changed

+4270
-57
lines changed

.prettierrc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
tabWidth: 4
2+
printWidth: 100
3+
singleQuote: true
4+
arrowParens: always
5+
trailingComma: all

jest.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module.exports = {
2+
moduleFileExtensions: ['js', 'ts'],
3+
transform: {
4+
'^.+\\.ts$': 'ts-jest',
5+
},
6+
testEnvironment: 'node',
7+
setupFiles: ['<rootDir>/setupJest.ts'],
8+
};

package.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,26 @@
1111
"license": "MIT",
1212
"private": false,
1313
"devDependencies": {
14+
"@types/jest": "^24.0.23",
15+
"@types/node": "^12.12.9",
16+
"@types/node-fetch": "^2.5.3",
17+
"jest": "^24.9.0",
18+
"jest-fetch-mock": "^2.1.2",
1419
"prettier": "^1.19.1",
1520
"rimraf": "^3.0.0",
21+
"ts-jest": "^24.1.0",
1622
"tslib": "^1.10.0",
1723
"typescript": "^3.7.2"
1824
},
1925
"scripts": {
2026
"clean": "rimraf dist",
2127
"build": "tsc --project .",
22-
"start": "yarn build --watch"
28+
"start": "yarn build --incremental --watch",
29+
"test": "jest",
30+
"prettier": "prettier --write './src/**/*.{ts,js}'"
31+
},
32+
"dependencies": {
33+
"node-fetch": "^2.6.0",
34+
"query-string": "^6.9.0"
2335
}
2436
}

setupJest.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { GlobalWithFetchMock } from 'jest-fetch-mock';
2+
3+
// As suggested in jest-fetch-mock docs
4+
// https://github.com/jefflau/jest-fetch-mock#typescript-guide
5+
// With addition of node-fetch mock implementation.
6+
const customGlobal: GlobalWithFetchMock = global as GlobalWithFetchMock;
7+
customGlobal.fetch = require('jest-fetch-mock');
8+
customGlobal.fetchMock = customGlobal.fetch;
9+
jest.setMock('node-fetch', customGlobal.fetch);

src/Api/Api.test.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { Response } from 'node-fetch';
2+
3+
import { createUrlWithQuery } from './lib';
4+
import { createFakeErrorPayload } from './createRequest';
5+
import Api from './Api';
6+
import { Method } from './types';
7+
8+
const API_URL_CORRECT = 'http://rock.prezly.test/api/v1/contacts';
9+
const API_URL_INCORRECT = 'htp:/rock.prezly.test/api/v1/contacts';
10+
11+
const DEFAULT_REQUEST_PROPS = {
12+
body: undefined,
13+
headers: {
14+
Accept: 'application/json',
15+
'Content-Type': 'application/json;charset=utf-8',
16+
},
17+
};
18+
19+
function successJsonResponse(body: object) {
20+
return new Response(JSON.stringify(body), {
21+
status: 200,
22+
statusText: 'OK',
23+
headers: {
24+
'Content-Type': 'application/json',
25+
},
26+
});
27+
}
28+
29+
function errorJSONResponse(body: object) {
30+
return new Response(JSON.stringify(body), {
31+
status: 500,
32+
statusText: 'Internal Server Error',
33+
headers: {
34+
'Content-Type': 'application/json',
35+
},
36+
});
37+
}
38+
39+
describe('Api', () => {
40+
it('should resolve with correct payload', async () => {
41+
const expectedPayload = {
42+
foo: 'bar',
43+
};
44+
45+
const expectedResponse = successJsonResponse(expectedPayload);
46+
global.fetch.mockResolvedValueOnce(expectedResponse);
47+
48+
const actualResponse = await Api.get(API_URL_CORRECT);
49+
50+
expect(actualResponse.status).toEqual(200);
51+
expect(actualResponse.payload).toEqual(expectedPayload);
52+
});
53+
54+
it('should reject with correct payload', async () => {
55+
const expectedPayload = {
56+
foo: 'bar',
57+
};
58+
59+
const expectedResponse = errorJSONResponse(expectedPayload);
60+
global.fetch.mockResolvedValueOnce(expectedResponse);
61+
62+
try {
63+
await Api.get(API_URL_CORRECT);
64+
} catch ({ status, payload }) {
65+
expect(status).toEqual(500);
66+
expect(payload).toEqual(expectedPayload);
67+
}
68+
});
69+
70+
it('should reject with Invalid URL provided', async () => {
71+
const errorMessage = 'Invalid URL provided';
72+
// Fetch mock doesn't validate the URL so we mock the error.
73+
global.fetch.mockRejectOnce(new Error(errorMessage));
74+
try {
75+
await Api.get(API_URL_INCORRECT);
76+
} catch ({ payload }) {
77+
const expectedErrorResponse = createFakeErrorPayload({
78+
status: undefined,
79+
statusText: errorMessage,
80+
});
81+
82+
expect(payload).toEqual(expectedErrorResponse);
83+
}
84+
});
85+
86+
it('should create a GET request', async () => {
87+
const response = successJsonResponse({});
88+
89+
global.fetch.mockResolvedValueOnce(response);
90+
91+
await Api.get(API_URL_CORRECT);
92+
93+
const expectedUrl = createUrlWithQuery(API_URL_CORRECT);
94+
95+
expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
96+
...DEFAULT_REQUEST_PROPS,
97+
method: Method.GET,
98+
});
99+
});
100+
101+
it('should create a GET request with query params', async () => {
102+
const response = successJsonResponse({});
103+
global.fetch.mockResolvedValueOnce(response);
104+
105+
const query = { foo: 'bar' };
106+
await Api.get(API_URL_CORRECT, { query });
107+
108+
const expectedUrl = createUrlWithQuery(API_URL_CORRECT, query);
109+
110+
expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
111+
...DEFAULT_REQUEST_PROPS,
112+
method: Method.GET,
113+
});
114+
});
115+
116+
it('should create a POST request', async () => {
117+
const response = successJsonResponse({});
118+
global.fetch.mockResolvedValueOnce(response);
119+
120+
const query = {
121+
foo: 'bar',
122+
};
123+
124+
const payload = {
125+
foo: 'bar',
126+
};
127+
128+
await Api.post(API_URL_CORRECT, { query, payload });
129+
130+
const expectedUrl = createUrlWithQuery(API_URL_CORRECT, query);
131+
132+
expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
133+
...DEFAULT_REQUEST_PROPS,
134+
method: Method.POST,
135+
body: JSON.stringify(payload),
136+
});
137+
});
138+
139+
it('should create a PUT request', async () => {
140+
const response = successJsonResponse({});
141+
global.fetch.mockResolvedValueOnce(response);
142+
143+
const query = {
144+
foo: 'bar',
145+
};
146+
147+
const payload = {
148+
foo: 'bar',
149+
};
150+
151+
await Api.put(API_URL_CORRECT, { query, payload });
152+
153+
const expectedUrl = createUrlWithQuery(API_URL_CORRECT, query);
154+
155+
expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
156+
...DEFAULT_REQUEST_PROPS,
157+
method: Method.PUT,
158+
body: JSON.stringify(payload),
159+
});
160+
});
161+
162+
it('should create a PATCH request', async () => {
163+
const response = successJsonResponse({});
164+
global.fetch.mockResolvedValueOnce(response);
165+
166+
const query = {
167+
foo: 'bar',
168+
};
169+
170+
const payload = {
171+
foo: 'bar',
172+
};
173+
174+
await Api.patch(API_URL_CORRECT, { query, payload });
175+
176+
const expectedUrl = createUrlWithQuery(API_URL_CORRECT, query);
177+
178+
expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
179+
...DEFAULT_REQUEST_PROPS,
180+
method: Method.PATCH,
181+
body: JSON.stringify(payload),
182+
});
183+
});
184+
185+
it('should create a DELETE request', async () => {
186+
const response = successJsonResponse({});
187+
global.fetch.mockResolvedValueOnce(response);
188+
189+
const query = {
190+
foo: 'bar',
191+
};
192+
193+
await Api.delete(API_URL_CORRECT, { query });
194+
195+
const expectedUrl = createUrlWithQuery(API_URL_CORRECT, query);
196+
197+
expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
198+
...DEFAULT_REQUEST_PROPS,
199+
method: Method.DELETE,
200+
});
201+
});
202+
203+
it('should create a DELETE request (with body)', async () => {
204+
const response = successJsonResponse({});
205+
global.fetch.mockResolvedValueOnce(response);
206+
207+
const query = {
208+
foo: 'bar',
209+
};
210+
211+
const payload = {
212+
foo: 'bar',
213+
};
214+
215+
await Api.delete(API_URL_CORRECT, { query, payload });
216+
217+
const expectedUrl = createUrlWithQuery(API_URL_CORRECT, query);
218+
219+
expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
220+
...DEFAULT_REQUEST_PROPS,
221+
method: Method.DELETE,
222+
body: JSON.stringify(payload),
223+
});
224+
});
225+
});

src/Api/Api.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Method, HeadersMap, Response } from './types';
2+
import createRequest from './createRequest';
3+
4+
const Api = {
5+
get: <P = any>(
6+
url: string,
7+
{ query, headers }: { headers?: HeadersMap; query?: object } = {},
8+
): Promise<Response<P>> =>
9+
createRequest(url, {
10+
method: Method.GET,
11+
headers,
12+
query,
13+
}),
14+
15+
post: <P = any>(
16+
url: string,
17+
{
18+
headers,
19+
query,
20+
payload,
21+
}: { headers?: HeadersMap; query?: object; payload?: object } = {},
22+
): Promise<Response<P>> =>
23+
createRequest(url, {
24+
method: Method.POST,
25+
headers,
26+
query,
27+
payload,
28+
}),
29+
30+
put: <P = any>(
31+
url: string,
32+
{
33+
headers,
34+
query,
35+
payload,
36+
}: { headers?: HeadersMap; query?: object; payload?: object } = {},
37+
): Promise<Response<P>> =>
38+
createRequest(url, {
39+
method: Method.PUT,
40+
headers,
41+
query,
42+
payload,
43+
}),
44+
45+
patch: <P = any>(
46+
url: string,
47+
{
48+
headers,
49+
query,
50+
payload,
51+
}: { headers?: HeadersMap; query?: object; payload?: object } = {},
52+
): Promise<Response<P>> =>
53+
createRequest(url, {
54+
method: Method.PATCH,
55+
headers,
56+
query,
57+
payload,
58+
}),
59+
60+
delete: <P = any>(
61+
url: string,
62+
{
63+
headers,
64+
query,
65+
payload,
66+
}: { headers?: HeadersMap; query?: object; payload?: object } = {},
67+
): Promise<Response<P>> =>
68+
createRequest(url, {
69+
method: Method.DELETE,
70+
headers,
71+
query,
72+
payload,
73+
}),
74+
};
75+
76+
export default Api;

src/Api/ApiError.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { ApiErrorPayload, HeadersMap, Response } from './types';
2+
3+
export default class ApiError<P = ApiErrorPayload> extends Error implements Response<P> {
4+
payload: P;
5+
status: number;
6+
statusText: string;
7+
headers: HeadersMap;
8+
9+
constructor({
10+
payload,
11+
status = 0,
12+
statusText = 'Unspecified error',
13+
headers = {},
14+
}: {
15+
payload: P;
16+
status: number;
17+
statusText: string;
18+
headers: HeadersMap;
19+
}) {
20+
super(`API Error (${status}): ${statusText}`);
21+
this.payload = payload;
22+
this.status = status;
23+
this.statusText = statusText;
24+
this.headers = headers;
25+
}
26+
}

src/Api/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const CONTENT_TYPE = 'application/json;charset=utf-8';
2+
export const INVALID_URL_ERROR_MESSAGE = 'Invalid URL provided';
3+
export const NETWORK_PROBLEM_ERROR_MESSAGE = 'Network problem';

0 commit comments

Comments
 (0)