add fetcher creator, create fetcher for every case, remove api.test.ts because of test become usless in terms of using URLSearchParams

This commit is contained in:
Pavel Mineev
2021-02-02 15:53:40 -06:00
committed by Umputun
parent 49b70b7b04
commit f31146a266
5 changed files with 182 additions and 239 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
import { Comment } from './types';
import fetcher from './fetcher';
import { apiFetcher } from './fetcher';
export default function getLastComments(siteId: string, max: number): Promise<Comment[]> {
return fetcher.get(`/last/${max}?site=${siteId}`);
return apiFetcher.get(`/last/${max}?site=${siteId}`);
}
-40
View File
@@ -1,40 +0,0 @@
import jestFetchMock from 'jest-fetch-mock';
import { emailVerificationForSubscribe } from './api';
jest.mock('common/constants', () => ({
BASE_URL: 'https://example.com',
API_BASE: '/api',
}));
jest.mock('common/settings', () => ({
siteId: 'remark42',
}));
describe('api', () => {
beforeAll(() => {
jestFetchMock.enableMocks();
});
afterAll(() => {
jestFetchMock.disableMocks();
});
beforeEach(() => {
jestFetchMock.resetMocks();
});
it('should send request with encoded email', async () => {
await emailVerificationForSubscribe("address.!#$%&'*+-/=?^_`{|}~(),:;<>[\\]@example.com");
expect(jestFetchMock.mock.calls.length).toEqual(1);
const url = jestFetchMock.mock.calls[0][0] as string;
const match = url.match(/address=(\S+)$/) as string[];
expect(Array.isArray(match)).toBe(true);
expect(match.length).toBeGreaterThan(1);
expect(match[1]).toBe(
"address.!%23%24%25%26'*%2B-%2F%3D%3F%5E_%60%7B%7C%7D~()%2C%3A%3B%3C%3E%5B%5C%5D%40example.com"
);
});
});
+73 -64
View File
@@ -1,17 +1,22 @@
import { siteId, url } from './settings';
import { BASE_URL, API_BASE } from './constants';
import { Config, Comment, Tree, User, BlockedUser, Sorting, AuthProvider, BlockTTL, Image } from './types';
import fetcher, { stringifyUrl } from './fetcher';
import createFetcher, { apiFetcher } from './fetcher';
const authFetcher = createFetcher(`${BASE_URL}/auth`);
const adminFetcher = createFetcher(`${BASE_URL}${API_BASE}/admin`);
/* Auth methods */
const FROM_URL = `${window.location.origin}${window.location.pathname}?selfClose`;
/* common */
const __loginAnonymously = (username: string): Promise<User | null> =>
fetcher.get<User>('/auth/anonymous/login', {
authFetcher.get<User>('/anonymous/login', {
user: username,
aud: siteId,
from: `${window.location.origin}${window.location.pathname}?selfClose`,
from: FROM_URL,
});
const __loginViaEmail = (token: string): Promise<User | null> => fetcher.get<User>('/auth/email/login', { token });
const __loginViaEmail = (token: string): Promise<User | null> => authFetcher.get<User>('/email/login', { token });
/**
* First step of two of `email` authorization
@@ -20,16 +25,15 @@ const __loginViaEmail = (token: string): Promise<User | null> => fetcher.get<Use
* @param address email address
*/
export const sendEmailVerificationRequest = (username: string, address: string): Promise<void> =>
fetcher.get('/auth/email/login', { id: siteId, user: username, address });
authFetcher.get('/email/login', { id: siteId, user: username, address });
export const logIn = (provider: AuthProvider): Promise<User | null> => {
if (provider.name === 'anonymous') return __loginAnonymously(provider.username);
if (provider.name === 'email') return __loginViaEmail(provider.token);
return new Promise<User | null>((resolve, reject) => {
const url = stringifyUrl(`/auth/${provider.name}/login`, {
from: `${window.location.origin}${window.location.pathname}?selfClose`,
});
const queryString = new URLSearchParams({ from: FROM_URL });
const url = `/${provider.name}/login?${queryString}`;
const newWindow = window.open(url);
let secondsPass = 0;
const checkMsDelay = 300;
@@ -55,13 +59,15 @@ export const logIn = (provider: AuthProvider): Promise<User | null> => {
});
};
export const logOut = (): Promise<void> => fetcher.get('/auth/logout');
export const logOut = (): Promise<void> => authFetcher.get('/logout');
export const getConfig = (): Promise<Config> => fetcher.get('/config');
/* API methods */
export const getPostComments = (sort: Sorting) => fetcher.get<Tree>('/find', { url, sort, format: 'tree' });
export const getConfig = (): Promise<Config> => apiFetcher.get('/config');
export const getComment = (id: Comment['id']): Promise<Comment> => fetcher.get(`/id/${id}`, { url });
export const getPostComments = (sort: Sorting) => apiFetcher.get<Tree>('/find', { url, sort, format: 'tree' });
export const getComment = (id: Comment['id']): Promise<Comment> => apiFetcher.get(`/id/${id}`, { url });
export const getUserComments = (
userId: User['id'],
@@ -69,10 +75,10 @@ export const getUserComments = (
): Promise<{
comments: Comment[];
count: number;
}> => fetcher.get('/comments', { user: userId, limit });
}> => apiFetcher.get('/comments', { user: userId, limit });
export const putCommentVote = ({ id, value }: { id: Comment['id']; value: number }): Promise<void> =>
fetcher.put(`/vote/${id}`, { url, vote: value });
apiFetcher.put(`/vote/${id}`, { url, vote: value });
export const addComment = ({
title,
@@ -83,7 +89,7 @@ export const addComment = ({
text: string;
pid?: Comment['id'];
}): Promise<Comment> =>
fetcher.post(
apiFetcher.post(
'/comment',
{},
{
@@ -95,32 +101,65 @@ export const addComment = ({
);
export const updateComment = ({ text, id }: { text: string; id: Comment['id'] }): Promise<Comment> =>
fetcher.put(`/comment/${id}`, { url }, { text });
apiFetcher.put(`/comment/${id}`, { url }, { text });
export const getPreview = (text: string): Promise<string> => fetcher.post('/preview', {}, { text });
export const getPreview = (text: string): Promise<string> => apiFetcher.post('/preview', {}, { text });
export const getUser = (): Promise<User | null> => fetcher.get<User | null>('/user').catch(() => null);
export const getUser = (): Promise<User | null> => apiFetcher.get<User | null>('/user').catch(() => null);
/* GDPR */
export const uploadImage = (image: File): Promise<Image> => {
const data = new FormData();
data.append('file', image);
export const deleteMe = (): Promise<{ user_id: string; link: string }> => fetcher.post('/deleteme');
return apiFetcher.post<{ id: string }>('/picture', {}, data).then((resp) => ({
name: image.name,
size: image.size,
type: image.type,
url: `${BASE_URL + API_BASE}/picture/${resp.id}`,
}));
};
export const approveDeleteMe = (token: string): Promise<void> => fetcher.get('/admin/deleteme', { token });
/* Subscription methods */
/* admin */
export const pinComment = (id: Comment['id']): Promise<void> => fetcher.put(`/admin/pin/${id}`, { url, pin: 1 });
/**
* Start process of email subscription to updates
* @param emailAddress email for subscription
*/
export const emailVerificationForSubscribe = (emailAddress: string) =>
apiFetcher.post('/email/subscribe', { address: emailAddress });
export const unpinComment = (id: Comment['id']): Promise<void> => fetcher.put(`/admin/pin/${id}`, { url, pin: 0 });
/**
* Confirmation of email subscription to updates
* @param token confirmation token from email
*/
export const emailConfirmationForSubscribe = (token: string) => apiFetcher.post('/email/confirm', { tkn: token });
export const setVerifiedStatus = (id: User['id']): Promise<void> => fetcher.put(`/admin/verify/${id}`, { verified: 1 });
/**
* Decline current subscription to updates
*/
export const unsubscribeFromEmailUpdates = () => apiFetcher.delete('/email');
/* GDPR Methods */
export const deleteMe = (): Promise<{ user_id: string; link: string }> => apiFetcher.post('/deleteme');
/* Admin Methods */
// TODO: move these methods to separate chunk as well as all admin inteface features
export const approveDeleteMe = (token: string): Promise<void> => adminFetcher.get('/deleteme', { token });
export const pinComment = (id: Comment['id']): Promise<void> => adminFetcher.put(`/pin/${id}`, { url, pin: 1 });
export const unpinComment = (id: Comment['id']): Promise<void> => adminFetcher.put(`/pin/${id}`, { url, pin: 0 });
export const setVerifiedStatus = (id: User['id']): Promise<void> => adminFetcher.put(`/verify/${id}`, { verified: 1 });
export const removeVerifiedStatus = (id: User['id']): Promise<void> =>
fetcher.put(`/admin/verify/${id}`, { verified: 0 });
adminFetcher.put(`/verify/${id}`, { verified: 0 });
export const removeComment = (id: Comment['id']): Promise<void> => fetcher.delete(`/admin/comment/${id}`, { url });
export const removeComment = (id: Comment['id']): Promise<void> => adminFetcher.delete(`/comment/${id}`, { url });
export const removeMyComment = (id: Comment['id']): Promise<void> =>
fetcher.put(`/comment/${id}`, { url }, { delete: true });
adminFetcher.put(`/comment/${id}`, { url }, { delete: true });
export const blockUser = (
id: User['id'],
@@ -129,7 +168,7 @@ export const blockUser = (
block: boolean;
site_id: string;
user_id: string;
}> => fetcher.put(`/admin/user/${id}`, { block: 1, ttl: ttl === 'permanently' ? ttl : undefined });
}> => adminFetcher.put(`/user/${id}`, { block: 1, ttl: ttl === 'permanently' ? ttl : undefined });
export const unblockUser = (
id: User['id']
@@ -137,40 +176,10 @@ export const unblockUser = (
block: boolean;
site_id: string;
user_id: string;
}> => fetcher.put(`/admin/user/${id}`, { block: 0 });
}> => adminFetcher.put(`/user/${id}`, { block: 0 });
export const getBlocked = (): Promise<BlockedUser[] | null> => fetcher.get('/admin/blocked');
export const getBlocked = (): Promise<BlockedUser[] | null> => adminFetcher.get('/blocked');
export const disableComments = (): Promise<void> => fetcher.put('/admin/readonly', { url, ro: 1 });
export const disableComments = (): Promise<void> => adminFetcher.put('/readonly', { url, ro: 1 });
export const enableComments = (): Promise<void> => fetcher.put('/admin/readonly', { url, ro: 0 });
export const uploadImage = (image: File): Promise<Image> => {
const data = new FormData();
data.append('file', image);
return fetcher.post<{ id: string }>('/picture', {}, data).then((resp) => ({
name: image.name,
size: image.size,
type: image.type,
url: `${BASE_URL + API_BASE}/picture/${resp.id}`,
}));
};
/**
* Start process of email subscription to updates
* @param emailAddress email for subscription
*/
export const emailVerificationForSubscribe = (emailAddress: string) =>
fetcher.post('/email/subscribe', { address: emailAddress });
/**
* Confirmation of email subscription to updates
* @param token confirmation token from email
*/
export const emailConfirmationForSubscribe = (token: string) => fetcher.post('/email/confirm', { tkn: token });
/**
* Decline current subscription to updates
*/
export const unsubscribeFromEmailUpdates = () => fetcher.delete('/email');
export const enableComments = (): Promise<void> => adminFetcher.put('/readonly', { url, ro: 0 });
+33 -40
View File
@@ -3,8 +3,7 @@ jest.mock('./settings', () => ({
}));
import { RequestError } from 'utils/errorUtils';
import { API_BASE, BASE_URL } from './constants.config';
import fetcher, { JWT_HEADER, XSRF_HEADER } from './fetcher';
import createFetcher, { JWT_HEADER, XSRF_HEADER } from './fetcher';
type FetchImplementaitonProps = {
status?: number;
@@ -31,62 +30,56 @@ function mockFetch({ headers = {}, data = {}, ...props }: FetchImplementaitonPro
}
describe('fetcher', () => {
const apiFetcher = createFetcher('/api');
const headers = { [XSRF_HEADER]: '' };
const authUrl = `${BASE_URL}/auth?site=remark`;
const commentsUrl = `${BASE_URL}${API_BASE}/comments?site=remark`;
const apiUri = '/anything';
const apiUrl = `/api/anything?site=remark`;
describe('methods', () => {
it('should send GET request', async () => {
expect.assertions(1);
mockFetch();
await fetcher.get('/auth');
await apiFetcher.get(apiUri);
expect(window.fetch).toHaveBeenCalledWith(authUrl, { method: 'get', headers });
expect(window.fetch).toHaveBeenCalledWith(apiUrl, { method: 'get', headers });
});
it('should send POST request', async () => {
expect.assertions(1);
mockFetch();
await fetcher.post('/auth');
await apiFetcher.post(apiUri);
expect(window.fetch).toHaveBeenCalledWith(authUrl, { method: 'post', headers });
expect(window.fetch).toHaveBeenCalledWith(apiUrl, { method: 'post', headers });
});
it('should send PUT request', async () => {
expect.assertions(1);
mockFetch();
await fetcher.put('/auth');
await apiFetcher.put(apiUri);
expect(window.fetch).toHaveBeenCalledWith(authUrl, { method: 'put', headers });
expect(window.fetch).toHaveBeenCalledWith(apiUrl, { method: 'put', headers });
});
it('should send DELETE request', async () => {
expect.assertions(1);
mockFetch();
await fetcher.delete('/auth');
await apiFetcher.delete(apiUri);
expect(window.fetch).toHaveBeenCalledWith(authUrl, { method: 'delete', headers });
expect(window.fetch).toHaveBeenCalledWith(apiUrl, { method: 'delete', headers });
});
});
describe('endpoint formation', () => {
it("shouldn't add API_BASE for requests to auth endpoints", async () => {
describe('base url', () => {
const authFetcher = createFetcher('/auth');
it('should use other base url for auth fetcher', async () => {
expect.assertions(1);
mockFetch();
await fetcher.post('/auth');
await authFetcher.post(apiUri);
expect(window.fetch).toHaveBeenCalledWith(authUrl, { method: 'post', headers });
});
it('should add API_BASE for requests to auth endpoints', async () => {
expect.assertions(1);
mockFetch();
await fetcher.post('/comments');
expect(window.fetch).toHaveBeenCalledWith(commentsUrl, { method: 'post', headers });
expect(window.fetch).toHaveBeenCalledWith('/auth/anything?site=remark', { method: 'post', headers });
});
});
@@ -97,31 +90,31 @@ describe('fetcher', () => {
const headersWithJwt = { [JWT_HEADER]: 'token', ...headers };
// Set token to `activeJwtToken`
mockFetch({ headers: headersWithJwt });
await fetcher.get('/comments');
await apiFetcher.get(apiUri);
expect(window.fetch).toHaveBeenCalled();
// Check if `activeJwtToken` saved and clean
mockFetch({ headers, status: 401 });
await fetcher
.get('/comments')
await apiFetcher
.get(apiUri)
.then(() => {
throw Error('Fetcher shoud throw error on 401 responce');
throw Error('apiFether shoud throw error on 401 responce');
})
.catch((e) => {
expect(e.message).toBe('Not authorized.');
});
expect(window.fetch).toHaveBeenCalledWith(commentsUrl, {
expect(window.fetch).toHaveBeenCalledWith(apiUrl, {
method: 'get',
headers: headersWithJwt,
});
// Check if `activeJwtToken` was cleaned
mockFetch({ headers });
await fetcher.get('/comments');
await apiFetcher.get(apiUri);
expect(window.fetch).toHaveBeenCalledWith(commentsUrl, { method: 'get', headers });
expect(window.fetch).toHaveBeenCalledWith(apiUrl, { method: 'get', headers });
});
});
@@ -133,9 +126,9 @@ describe('fetcher', () => {
const headersWithContentType = { ...headers, 'Content-Type': 'application/json' };
mockFetch();
await fetcher.post('/comments', {}, data);
await apiFetcher.post(apiUri, {}, data);
expect(window.fetch).toBeCalledWith(commentsUrl, {
expect(window.fetch).toBeCalledWith(apiUrl, {
method: 'post',
headers: headersWithContentType,
body: JSON.stringify(data),
@@ -148,9 +141,9 @@ describe('fetcher', () => {
const body = new FormData();
mockFetch();
await fetcher.post('/comments', {}, body);
await apiFetcher.post(apiUri, {}, body);
expect(window.fetch).toHaveBeenCalledWith(commentsUrl, {
expect(window.fetch).toHaveBeenCalledWith(apiUrl, {
method: 'post',
body,
headers: headersWithMultipartData,
@@ -170,7 +163,7 @@ describe('fetcher', () => {
mockFetch({ status: 400, data });
await expect(fetcher.get('/anything')).rejects.toEqual(data);
await expect(apiFetcher.get(apiUri)).rejects.toEqual(data);
});
it('should throw error on api json response with >= 400 status code and bad json from server', async () => {
@@ -180,7 +173,7 @@ describe('fetcher', () => {
mockFetch({ status: 400, data });
await expect(fetcher.get('/anything')).rejects.toEqual(data);
await expect(apiFetcher.get(apiUri)).rejects.toEqual(data);
});
it('should throw special error object on 401 status', async () => {
@@ -198,7 +191,7 @@ describe('fetcher', () => {
},
});
await expect(fetcher.get('/anything')).rejects.toEqual(new RequestError('Not authorized.', 401));
await expect(apiFetcher.get(apiUri)).rejects.toEqual(new RequestError('Not authorized.', 401));
});
it('should throw "Something went wrong." object on unknown status', async () => {
expect.assertions(1);
@@ -213,7 +206,7 @@ describe('fetcher', () => {
},
});
await expect(fetcher.get('/anything')).rejects.toEqual(new RequestError('Something went wrong.', 0));
await expect(apiFetcher.get(apiUri)).rejects.toEqual(new RequestError('Something went wrong.', 0));
});
});
});
+74 -93
View File
@@ -19,110 +19,91 @@ type Payload = BodyInit | Record<string, unknown> | null;
type FetcherMethods = Record<'get' | 'delete', <T>(url: string, query?: QueryParams) => Promise<T>> &
Record<'put' | 'post', <T>(url: string, query?: QueryParams, body?: Payload) => Promise<T>>;
export function stringifyUrl(uri: string, query: QueryParams) {
const queryEntries = Object.entries(query);
const siteIdParam = `site=${encodeURIComponent(siteId)}`;
// we use `indexOf` instead of `startsWidth` because we don't want to have another one polyfill for no reason
const baseUrl = uri.indexOf('/auth') === 0 ? BASE_URL : `${BASE_URL}${API_BASE}`;
const url = `${baseUrl}${uri}`;
if (queryEntries.length === 0) {
return `${url}?${siteIdParam}`;
}
const queryString = Object.entries(query)
.reduce(
(accum, [k, v]) => (v === undefined ? accum : [...accum, `${encodeURIComponent(k)}=${encodeURIComponent(v)}`]),
[siteIdParam] as string[]
)
.join('&');
return `${url}?${queryString}`;
}
/** JWT token received from server and will be send by each request, if it present */
let activeJwtToken: string | undefined;
const fetcher = METHODS.reduce<FetcherMethods>((acc, method) => {
/**
* Fetcher is abstraction on top of fetch
*
* @uri uri to API endpoint. BASE_URL and API_BASE will be added automatically. For auth requests API_BASE will be dropped.
* @query - collection of query params. They will be concatenated to URL. `siteId` will be added automatically.
* @body - data for sending to the server. If you pass object it will be stringified. If you pass form data it will be sent as is. Content type headers will be added automatically.
*/
acc[method] = async (uri: string, query: QueryParams = {}, body?: Payload) => {
// add api base if it's not auth request
const url = stringifyUrl(uri, query);
const headers: Record<string, string> = {};
const params: RequestInit = { method };
const createFetcher = (baseUrl: string = '') =>
METHODS.reduce<FetcherMethods>((acc, method) => {
/**
* Fetcher is abstraction on top of fetch
*
* @uri uri to API endpoint
* @query - collection of query params. They will be concatenated to URL. `siteId` will be added automatically.
* @body - data for sending to the server. If you pass object it will be stringified. If you pass form data it will be sent as is. Content type headers will be added automatically.
*/
acc[method] = async (uri: string, query: QueryParams = {}, body?: Payload) => {
const queryString = new URLSearchParams({ ...query, site: siteId });
const url = `${baseUrl}${uri}?${queryString}`;
const headers: Record<string, string> = {};
const params: RequestInit = { method };
// Save token in memory and pass it into headers in case if storing cookies is disabled
if (activeJwtToken) {
headers[JWT_HEADER] = activeJwtToken;
}
headers[XSRF_HEADER] = getCookie(XSRF_COOKIE) || '';
// Save token in memory and pass it into headers in case if storing cookies is disabled
if (activeJwtToken) {
headers[JWT_HEADER] = activeJwtToken;
}
headers[XSRF_HEADER] = getCookie(XSRF_COOKIE) || '';
if (body instanceof FormData) {
headers['Content-Type'] = 'multipart/form-data';
params.body = body;
} else if (typeof body === 'object' && body !== null) {
headers['Content-Type'] = 'application/json';
params.body = JSON.stringify(body);
} else {
params.body = body;
}
try {
const res = await fetch(url, { ...params, headers });
// TODO: it should be clarified when frontend gets this header and what could be in it to simplify this logic and cover by tests
const date = (res.headers.has('date') && res.headers.get('date')) || '';
const timestamp = isNaN(Date.parse(date)) ? 0 : Date.parse(date);
const timeDiff = (new Date().getTime() - timestamp) / 1000;
StaticStore.serverClientTimeDiff = timeDiff;
// backend could update jwt in any time. so, we should handle it
if (res.headers.has(JWT_HEADER)) {
activeJwtToken = res.headers.get(JWT_HEADER) as string;
if (body instanceof FormData) {
headers['Content-Type'] = 'multipart/form-data';
params.body = body;
} else if (typeof body === 'object' && body !== null) {
headers['Content-Type'] = 'application/json';
params.body = JSON.stringify(body);
} else {
params.body = body;
}
if ([401, 403].includes(res.status)) {
activeJwtToken = undefined;
}
try {
const res = await fetch(url, { ...params, headers });
// TODO: it should be clarified when frontend gets this header and what could be in it to simplify this logic and cover by tests
const date = (res.headers.has('date') && res.headers.get('date')) || '';
const timestamp = isNaN(Date.parse(date)) ? 0 : Date.parse(date);
const timeDiff = (new Date().getTime() - timestamp) / 1000;
if (res.status >= 400) {
if (httpErrorMap.has(res.status)) {
const descriptor = httpErrorMap.get(res.status) || httpMessages.unexpectedError;
StaticStore.serverClientTimeDiff = timeDiff;
throw new RequestError(descriptor.defaultMessage, res.status);
// backend could update jwt in any time. so, we should handle it
if (res.headers.has(JWT_HEADER)) {
activeJwtToken = res.headers.get(JWT_HEADER) as string;
}
return res.text().then((text) => {
let err;
try {
err = JSON.parse(text);
} catch (e) {
throw new RequestError(httpMessages.unexpectedError.defaultMessage, 0);
if ([401, 403].includes(res.status)) {
activeJwtToken = undefined;
}
if (res.status >= 400) {
if (httpErrorMap.has(res.status)) {
const descriptor = httpErrorMap.get(res.status) || httpMessages.unexpectedError;
throw new RequestError(descriptor.defaultMessage, res.status);
}
throw err;
});
return res.text().then((text) => {
let err;
try {
err = JSON.parse(text);
} catch (e) {
throw new RequestError(httpMessages.unexpectedError.defaultMessage, 0);
}
throw err;
});
}
if (res.headers.get('Content-Type')?.indexOf('application/json') === 0) {
return res.json();
}
return res.text();
} catch (e) {
if (e?.message === 'Failed to fetch') {
throw new RequestError(e.message, -2);
}
throw e;
}
};
return acc;
}, {} as FetcherMethods);
if (res.headers.get('Content-Type')?.indexOf('application/json') === 0) {
return res.json();
}
return res.text();
} catch (e) {
if (e?.message === 'Failed to fetch') {
throw new RequestError(e.message, -2);
}
throw e;
}
};
return acc;
}, {} as FetcherMethods);
export default fetcher;
export const apiFetcher = createFetcher(`${BASE_URL}${API_BASE}`);
export default createFetcher;