Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
tabWidth: 4
printWidth: 100
singleQuote: true
arrowParens: always
trailingComma: all
8 changes: 8 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
moduleFileExtensions: ['js', 'ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
testEnvironment: 'node',
setupFiles: ['<rootDir>/setupJest.ts'],
};
14 changes: 13 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,26 @@
"license": "MIT",
"private": false,
"devDependencies": {
"@types/jest": "^24.0.23",
"@types/node": "^12.12.9",
"@types/node-fetch": "^2.5.3",
"jest": "^24.9.0",
"jest-fetch-mock": "^2.1.2",
"prettier": "^1.19.1",
"rimraf": "^3.0.0",
"ts-jest": "^24.1.0",
"tslib": "^1.10.0",
"typescript": "^3.7.2"
},
"scripts": {
"clean": "rimraf dist",
"build": "tsc --project .",
"start": "yarn build --watch"
"start": "yarn build --incremental --watch",
"test": "jest",
"prettier": "prettier --write './src/**/*.{ts,js}'"
},
"dependencies": {
"node-fetch": "^2.6.0",
"query-string": "^6.9.0"
}
}
9 changes: 9 additions & 0 deletions setupJest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { GlobalWithFetchMock } from 'jest-fetch-mock';

// As suggested in jest-fetch-mock docs
// https://github.com/jefflau/jest-fetch-mock#typescript-guide
// With addition of node-fetch mock implementation.
const customGlobal: GlobalWithFetchMock = global as GlobalWithFetchMock;
customGlobal.fetch = require('jest-fetch-mock');
customGlobal.fetchMock = customGlobal.fetch;
jest.setMock('node-fetch', customGlobal.fetch);
225 changes: 225 additions & 0 deletions src/Api/Api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { Response } from 'node-fetch';

import { createUrlWithQuery } from './lib';
import { createFakeErrorPayload } from './createRequest';
import Api from './Api';
import { Method } from './types';

const API_URL_CORRECT = 'http://rock.prezly.test/api/v1/contacts';
const API_URL_INCORRECT = 'htp:/rock.prezly.test/api/v1/contacts';

const DEFAULT_REQUEST_PROPS = {
body: undefined,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json;charset=utf-8',
},
};

function successJsonResponse(body: object) {
return new Response(JSON.stringify(body), {
status: 200,
statusText: 'OK',
headers: {
'Content-Type': 'application/json',
},
});
}

function errorJSONResponse(body: object) {
return new Response(JSON.stringify(body), {
status: 500,
statusText: 'Internal Server Error',
headers: {
'Content-Type': 'application/json',
},
});
}

describe('Api', () => {
it('should resolve with correct payload', async () => {
const expectedPayload = {
foo: 'bar',
};

const expectedResponse = successJsonResponse(expectedPayload);
global.fetch.mockResolvedValueOnce(expectedResponse);

const actualResponse = await Api.get(API_URL_CORRECT);

expect(actualResponse.status).toEqual(200);
expect(actualResponse.payload).toEqual(expectedPayload);
});

it('should reject with correct payload', async () => {
const expectedPayload = {
foo: 'bar',
};

const expectedResponse = errorJSONResponse(expectedPayload);
global.fetch.mockResolvedValueOnce(expectedResponse);

try {
await Api.get(API_URL_CORRECT);
} catch ({ status, payload }) {
expect(status).toEqual(500);
expect(payload).toEqual(expectedPayload);
}
});

it('should reject with Invalid URL provided', async () => {
const errorMessage = 'Invalid URL provided';
// Fetch mock doesn't validate the URL so we mock the error.
global.fetch.mockRejectOnce(new Error(errorMessage));
try {
await Api.get(API_URL_INCORRECT);
} catch ({ payload }) {
const expectedErrorResponse = createFakeErrorPayload({
status: undefined,
statusText: errorMessage,
});

expect(payload).toEqual(expectedErrorResponse);
}
});

it('should create a GET request', async () => {
const response = successJsonResponse({});

global.fetch.mockResolvedValueOnce(response);

await Api.get(API_URL_CORRECT);

const expectedUrl = createUrlWithQuery(API_URL_CORRECT);

expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
...DEFAULT_REQUEST_PROPS,
method: Method.GET,
});
});

it('should create a GET request with query params', async () => {
const response = successJsonResponse({});
global.fetch.mockResolvedValueOnce(response);

const query = { foo: 'bar' };
await Api.get(API_URL_CORRECT, { query });

const expectedUrl = createUrlWithQuery(API_URL_CORRECT, query);

expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
...DEFAULT_REQUEST_PROPS,
method: Method.GET,
});
});

it('should create a POST request', async () => {
const response = successJsonResponse({});
global.fetch.mockResolvedValueOnce(response);

const query = {
foo: 'bar',
};

const payload = {
foo: 'bar',
};

await Api.post(API_URL_CORRECT, { query, payload });

const expectedUrl = createUrlWithQuery(API_URL_CORRECT, query);

expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
...DEFAULT_REQUEST_PROPS,
method: Method.POST,
body: JSON.stringify(payload),
});
});

it('should create a PUT request', async () => {
const response = successJsonResponse({});
global.fetch.mockResolvedValueOnce(response);

const query = {
foo: 'bar',
};

const payload = {
foo: 'bar',
};

await Api.put(API_URL_CORRECT, { query, payload });

const expectedUrl = createUrlWithQuery(API_URL_CORRECT, query);

expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
...DEFAULT_REQUEST_PROPS,
method: Method.PUT,
body: JSON.stringify(payload),
});
});

it('should create a PATCH request', async () => {
const response = successJsonResponse({});
global.fetch.mockResolvedValueOnce(response);

const query = {
foo: 'bar',
};

const payload = {
foo: 'bar',
};

await Api.patch(API_URL_CORRECT, { query, payload });

const expectedUrl = createUrlWithQuery(API_URL_CORRECT, query);

expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
...DEFAULT_REQUEST_PROPS,
method: Method.PATCH,
body: JSON.stringify(payload),
});
});

it('should create a DELETE request', async () => {
const response = successJsonResponse({});
global.fetch.mockResolvedValueOnce(response);

const query = {
foo: 'bar',
};

await Api.delete(API_URL_CORRECT, { query });

const expectedUrl = createUrlWithQuery(API_URL_CORRECT, query);

expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
...DEFAULT_REQUEST_PROPS,
method: Method.DELETE,
});
});

it('should create a DELETE request (with body)', async () => {
const response = successJsonResponse({});
global.fetch.mockResolvedValueOnce(response);

const query = {
foo: 'bar',
};

const payload = {
foo: 'bar',
};

await Api.delete(API_URL_CORRECT, { query, payload });

const expectedUrl = createUrlWithQuery(API_URL_CORRECT, query);

expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {
...DEFAULT_REQUEST_PROPS,
method: Method.DELETE,
body: JSON.stringify(payload),
});
});
});
76 changes: 76 additions & 0 deletions src/Api/Api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Method, HeadersMap, Response } from './types';
import createRequest from './createRequest';

const Api = {
get: <P = any>(
url: string,
{ query, headers }: { headers?: HeadersMap; query?: object } = {},
): Promise<Response<P>> =>
createRequest(url, {
method: Method.GET,
headers,
query,
}),

post: <P = any>(
url: string,
{
headers,
query,
payload,
}: { headers?: HeadersMap; query?: object; payload?: object } = {},
): Promise<Response<P>> =>
createRequest(url, {
method: Method.POST,
headers,
query,
payload,
}),

put: <P = any>(
url: string,
{
headers,
query,
payload,
}: { headers?: HeadersMap; query?: object; payload?: object } = {},
): Promise<Response<P>> =>
createRequest(url, {
method: Method.PUT,
headers,
query,
payload,
}),

patch: <P = any>(
url: string,
{
headers,
query,
payload,
}: { headers?: HeadersMap; query?: object; payload?: object } = {},
): Promise<Response<P>> =>
createRequest(url, {
method: Method.PATCH,
headers,
query,
payload,
}),

delete: <P = any>(
url: string,
{
headers,
query,
payload,
}: { headers?: HeadersMap; query?: object; payload?: object } = {},
): Promise<Response<P>> =>
createRequest(url, {
method: Method.DELETE,
headers,
query,
payload,
}),
};

export default Api;
26 changes: 26 additions & 0 deletions src/Api/ApiError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ApiErrorPayload, HeadersMap, Response } from './types';

export default class ApiError<P = ApiErrorPayload> extends Error implements Response<P> {
payload: P;
status: number;
statusText: string;
headers: HeadersMap;

constructor({
payload,
status = 0,
statusText = 'Unspecified error',
headers = {},
}: {
payload: P;
status: number;
statusText: string;
headers: HeadersMap;
}) {
super(`API Error (${status}): ${statusText}`);
this.payload = payload;
this.status = status;
this.statusText = statusText;
this.headers = headers;
}
}
3 changes: 3 additions & 0 deletions src/Api/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const CONTENT_TYPE = 'application/json;charset=utf-8';
export const INVALID_URL_ERROR_MESSAGE = 'Invalid URL provided';
export const NETWORK_PROBLEM_ERROR_MESSAGE = 'Network problem';
Loading