diff --git a/frontend/app/common/api.ts b/frontend/app/common/api.ts index c6626be7..11e8572a 100644 --- a/frontend/app/common/api.ts +++ b/frontend/app/common/api.ts @@ -1,41 +1,17 @@ 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 from './fetcher'; - -// /auth/anonymous/login?user=sfdfsf&aud=remark&from=https%3A%2F%2Fdemo.remark42.com%2Fweb%2Fiframe.html%3FselfClose&site=remark -// /auth/anonymous/login?site=remark&username=asdasd&aud=remark&from=http://127.0.0.1:9000/web/iframe.html?selfClose - -function stringifyUrl(url: string, query: Record) { - const queryEntries = Object.entries(query); - const siteIdParam = `site=${encodeURIComponent(siteId)}`; - - 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}`; -} +import fetcher, { stringifyUrl } from './fetcher'; /* common */ const __loginAnonymously = (username: string): Promise => - fetcher.get( - stringifyUrl('/auth/anonymous/login', { - user: username, - aud: siteId, - from: `${window.location.origin}${window.location.pathname}?selfClose`, - }) - ); + fetcher.get('/auth/anonymous/login', { + user: username, + aud: siteId, + from: `${window.location.origin}${window.location.pathname}?selfClose`, + }); -const __loginViaEmail = (token: string): Promise => - fetcher.get(stringifyUrl('/auth/email/login', { token })); +const __loginViaEmail = (token: string): Promise => fetcher.get('/auth/email/login', { token }); /** * First step of two of `email` authorization @@ -44,14 +20,14 @@ const __loginViaEmail = (token: string): Promise => * @param address email address */ export const sendEmailVerificationRequest = (username: string, address: string): Promise => - fetcher.get(stringifyUrl('/auth/email/login', { id: siteId, user: username, address })); + fetcher.get('/auth/email/login', { id: siteId, user: username, address }); export const logIn = (provider: AuthProvider): Promise => { if (provider.name === 'anonymous') return __loginAnonymously(provider.username); if (provider.name === 'email') return __loginViaEmail(provider.token); return new Promise((resolve, reject) => { - const url = stringifyUrl(`${BASE_URL}/auth/${provider.name}/login`, { + const url = stringifyUrl(`/auth/${provider.name}/login`, { from: `${window.location.origin}${window.location.pathname}?selfClose`, }); const newWindow = window.open(url); @@ -83,10 +59,9 @@ export const logOut = (): Promise => fetcher.get('/auth/logout'); export const getConfig = (): Promise => fetcher.get('/config'); -export const getPostComments = (sort: Sorting) => - fetcher.get(stringifyUrl('/find', { url, sort, format: 'tree' })); +export const getPostComments = (sort: Sorting) => fetcher.get('/find', { url, sort, format: 'tree' }); -export const getComment = (id: Comment['id']): Promise => fetcher.get(stringifyUrl(`/id/${id}`, { url })); +export const getComment = (id: Comment['id']): Promise => fetcher.get(`/id/${id}`, { url }); export const getUserComments = ( userId: User['id'], @@ -94,10 +69,10 @@ export const getUserComments = ( ): Promise<{ comments: Comment[]; count: number; -}> => fetcher.get(stringifyUrl('/comments', { user: userId, limit })); +}> => fetcher.get('/comments', { user: userId, limit }); export const putCommentVote = ({ id, value }: { id: Comment['id']; value: number }): Promise => - fetcher.put(stringifyUrl(`/vote/${id}`, { url, vote: value })); + fetcher.put(`/vote/${id}`, { url, vote: value }); export const addComment = ({ title, @@ -108,19 +83,21 @@ export const addComment = ({ text: string; pid?: Comment['id']; }): Promise => - fetcher.post('/comment', { - json: { + fetcher.post( + '/comment', + {}, + { title, text, locator: { site: siteId, url }, ...(pid ? { pid } : {}), - }, - }); + } + ); export const updateComment = ({ text, id }: { text: string; id: Comment['id'] }): Promise => - fetcher.put(stringifyUrl(`/comment/${id}`, { url }), { json: { text } }); + fetcher.put(`/comment/${id}`, { url }, { text }); -export const getPreview = (text: string): Promise => fetcher.post('/preview', { json: { text } }); +export const getPreview = (text: string): Promise => fetcher.post('/preview', {}, { text }); export const getUser = (): Promise => fetcher.get('/user').catch(() => null); @@ -128,27 +105,22 @@ export const getUser = (): Promise => fetcher.get('/us export const deleteMe = (): Promise<{ user_id: string; link: string }> => fetcher.post('/deleteme'); -export const approveDeleteMe = (token: string): Promise => - fetcher.get(stringifyUrl('/admin/deleteme', { token })); +export const approveDeleteMe = (token: string): Promise => fetcher.get('/admin/deleteme', { token }); /* admin */ -export const pinComment = (id: Comment['id']): Promise => - fetcher.put(stringifyUrl(`/admin/pin/${id}`, { url, pin: 1 })); +export const pinComment = (id: Comment['id']): Promise => fetcher.put(`/admin/pin/${id}`, { url, pin: 1 }); -export const unpinComment = (id: Comment['id']): Promise => - fetcher.put(stringifyUrl(`/admin/pin/${id}`, { url, pin: 0 })); +export const unpinComment = (id: Comment['id']): Promise => fetcher.put(`/admin/pin/${id}`, { url, pin: 0 }); -export const setVerifiedStatus = (id: User['id']): Promise => - fetcher.put(stringifyUrl(`/admin/verify/${id}`, { verified: 1 })); +export const setVerifiedStatus = (id: User['id']): Promise => fetcher.put(`/admin/verify/${id}`, { verified: 1 }); export const removeVerifiedStatus = (id: User['id']): Promise => - fetcher.put(stringifyUrl(`/admin/verify/${id}`, { verified: 0 })); + fetcher.put(`/admin/verify/${id}`, { verified: 0 }); -export const removeComment = (id: Comment['id']): Promise => - fetcher.delete(stringifyUrl(`/admin/comment/${id}`, { url })); +export const removeComment = (id: Comment['id']): Promise => fetcher.delete(`/admin/comment/${id}`, { url }); export const removeMyComment = (id: Comment['id']): Promise => - fetcher.put(stringifyUrl(`/comment/${id}`, { url }), { json: { delete: true } }); + fetcher.put(`/comment/${id}`, { url }, { delete: true }); export const blockUser = ( id: User['id'], @@ -157,7 +129,7 @@ export const blockUser = ( block: boolean; site_id: string; user_id: string; -}> => fetcher.put(stringifyUrl(`/admin/user/${id}`, { block: 1, ttl: ttl === 'permanently' ? ttl : undefined })); +}> => fetcher.put(`/admin/user/${id}`, { block: 1, ttl: ttl === 'permanently' ? ttl : undefined }); export const unblockUser = ( id: User['id'] @@ -165,31 +137,24 @@ export const unblockUser = ( block: boolean; site_id: string; user_id: string; -}> => fetcher.put(stringifyUrl(`/admin/user/${id}`, { block: 0 })); +}> => fetcher.put(`/admin/user/${id}`, { block: 0 }); export const getBlocked = (): Promise => fetcher.get('/admin/blocked'); -export const disableComments = (): Promise => fetcher.put(stringifyUrl('/admin/readonly', { url, ro: 1 })); +export const disableComments = (): Promise => fetcher.put('/admin/readonly', { url, ro: 1 }); -export const enableComments = (): Promise => fetcher.put(stringifyUrl('/admin/readonly', { url, ro: 0 })); +export const enableComments = (): Promise => fetcher.put('/admin/readonly', { url, ro: 0 }); export const uploadImage = (image: File): Promise => { const data = new FormData(); data.append('file', image); - return fetcher - .post<{ id: string }>('/picture', { - headers: { - 'Content-Type': 'multipart/form-data', - }, - body: data, - }) - .then((resp) => ({ - name: image.name, - size: image.size, - type: image.type, - url: `${BASE_URL + API_BASE}/picture/${resp.id}`, - })); + 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}`, + })); }; /** @@ -197,14 +162,13 @@ export const uploadImage = (image: File): Promise => { * @param emailAddress email for subscription */ export const emailVerificationForSubscribe = (emailAddress: string) => - fetcher.post(stringifyUrl('/email/subscribe', { address: emailAddress })); + 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(stringifyUrl('/email/confirm', { tkn: token })); +export const emailConfirmationForSubscribe = (token: string) => fetcher.post('/email/confirm', { tkn: token }); /** * Decline current subscription to updates diff --git a/frontend/app/common/fetcher.test.ts b/frontend/app/common/fetcher.test.ts index d0d84c71..fc71457f 100644 --- a/frontend/app/common/fetcher.test.ts +++ b/frontend/app/common/fetcher.test.ts @@ -1,3 +1,7 @@ +jest.mock('./settings', () => ({ + siteId: 'remark', +})); + import { RequestError } from 'utils/errorUtils'; import { API_BASE, BASE_URL } from './constants.config'; import fetcher, { JWT_HEADER, XSRF_HEADER } from './fetcher'; @@ -28,6 +32,8 @@ function mockFetch({ headers = {}, data = {}, ...props }: FetchImplementaitonPro describe('fetcher', () => { const headers = { [XSRF_HEADER]: '' }; + const authUrl = `${BASE_URL}/auth?site=remark`; + const commentsUrl = `${BASE_URL}${API_BASE}/comments?site=remark`; describe('methods', () => { it('should send GET request', async () => { @@ -36,7 +42,7 @@ describe('fetcher', () => { mockFetch(); await fetcher.get('/auth'); - expect(window.fetch).toHaveBeenCalledWith(`${BASE_URL}/auth`, { method: 'get', headers }); + expect(window.fetch).toHaveBeenCalledWith(authUrl, { method: 'get', headers }); }); it('should send POST request', async () => { expect.assertions(1); @@ -44,7 +50,7 @@ describe('fetcher', () => { mockFetch(); await fetcher.post('/auth'); - expect(window.fetch).toHaveBeenCalledWith(`${BASE_URL}/auth`, { method: 'post', headers }); + expect(window.fetch).toHaveBeenCalledWith(authUrl, { method: 'post', headers }); }); it('should send PUT request', async () => { expect.assertions(1); @@ -52,7 +58,7 @@ describe('fetcher', () => { mockFetch(); await fetcher.put('/auth'); - expect(window.fetch).toHaveBeenCalledWith(`${BASE_URL}/auth`, { method: 'put', headers }); + expect(window.fetch).toHaveBeenCalledWith(authUrl, { method: 'put', headers }); }); it('should send DELETE request', async () => { expect.assertions(1); @@ -60,7 +66,7 @@ describe('fetcher', () => { mockFetch(); await fetcher.delete('/auth'); - expect(window.fetch).toHaveBeenCalledWith(`${BASE_URL}/auth`, { method: 'delete', headers }); + expect(window.fetch).toHaveBeenCalledWith(authUrl, { method: 'delete', headers }); }); }); @@ -69,9 +75,9 @@ describe('fetcher', () => { expect.assertions(1); mockFetch(); - await fetcher.post('/auth/google/login'); + await fetcher.post('/auth'); - expect(window.fetch).toHaveBeenCalledWith(`${BASE_URL}/auth/google/login`, { method: 'post', headers }); + expect(window.fetch).toHaveBeenCalledWith(authUrl, { method: 'post', headers }); }); it('should add API_BASE for requests to auth endpoints', async () => { @@ -80,7 +86,7 @@ describe('fetcher', () => { mockFetch(); await fetcher.post('/comments'); - expect(window.fetch).toHaveBeenCalledWith(`${BASE_URL}${API_BASE}/comments`, { method: 'post', headers }); + expect(window.fetch).toHaveBeenCalledWith(commentsUrl, { method: 'post', headers }); }); }); @@ -106,16 +112,16 @@ describe('fetcher', () => { expect(e.message).toBe('Not authorized.'); }); - expect(window.fetch).toHaveBeenCalledWith(`${BASE_URL}${API_BASE}/comments`, { + expect(window.fetch).toHaveBeenCalledWith(commentsUrl, { method: 'get', headers: headersWithJwt, }); // Check if `activeJwtToken` was cleaned mockFetch({ headers }); - await fetcher.get('/comments', { headers }); + await fetcher.get('/comments'); - expect(window.fetch).toHaveBeenCalledWith(`${BASE_URL}${API_BASE}/comments`, { method: 'get', headers }); + expect(window.fetch).toHaveBeenCalledWith(commentsUrl, { method: 'get', headers }); }); }); @@ -123,16 +129,16 @@ describe('fetcher', () => { it('should send JSON', async () => { expect.assertions(1); - const json = { text: 'text' }; + const data = { text: 'text' }; const headersWithContentType = { ...headers, 'Content-Type': 'application/json' }; mockFetch(); - await fetcher.post('/comment', { json }); + await fetcher.post('/comments', {}, data); - expect(window.fetch).toBeCalledWith(`${BASE_URL}${API_BASE}/comment`, { + expect(window.fetch).toBeCalledWith(commentsUrl, { method: 'post', headers: headersWithContentType, - body: JSON.stringify(json), + body: JSON.stringify(data), }); }); it('should send form data', async () => { @@ -142,9 +148,9 @@ describe('fetcher', () => { const body = new FormData(); mockFetch(); - await fetcher.post('/comment', { body, headers: headersWithMultipartData }); + await fetcher.post('/comments', {}, body); - expect(window.fetch).toHaveBeenCalledWith(`${BASE_URL}${API_BASE}/comment`, { + expect(window.fetch).toHaveBeenCalledWith(commentsUrl, { method: 'post', body, headers: headersWithMultipartData, diff --git a/frontend/app/common/fetcher.ts b/frontend/app/common/fetcher.ts index 6c65c6f1..40dc06c4 100644 --- a/frontend/app/common/fetcher.ts +++ b/frontend/app/common/fetcher.ts @@ -3,6 +3,7 @@ import { httpErrorMap, httpMessages, RequestError } from 'utils/errorUtils'; import { BASE_URL, API_BASE } from './constants'; import { StaticStore } from './static-store'; import { getCookie } from './cookies'; +import { siteId } from 'common/settings'; /** List of fetcher’s supported methods */ const METHODS = ['get', 'post', 'put', 'delete'] as const; @@ -13,24 +14,41 @@ export const XSRF_HEADER = 'X-XSRF-TOKEN'; /** Cookie field with XSRF token */ export const XSRF_COOKIE = 'XSRF-TOKEN'; -type Methods = typeof METHODS; -type FetchInit = Omit & { - headers?: Record; - json?: unknown; - query?: Record; -}; -type FetcherObject = Record(url: string, params?: FetchInit) => Promise>; +type QueryParams = Record; +type Payload = BodyInit | Record | null; +type FetcherMethods = Record<'get' | 'delete', (url: string, query?: QueryParams) => Promise> & + Record<'put' | 'post', (url: string, query?: QueryParams, body?: Payload) => Promise>; + +export function stringifyUrl(uri: string, query: QueryParams) { + const queryEntries = Object.entries(query); + const siteIdParam = `site=${encodeURIComponent(siteId)}`; + 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((acc, method) => { - acc[method] = async (uri: string, params: FetchInit = {}) => { - const { headers = {}, json, ...fetchParams } = params; +const fetcher = METHODS.reduce((acc, method) => { + acc[method] = async (uri: string, query: QueryParams = {}, body?: Payload) => { // add api base if it's not auth request // 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}`; + const url = stringifyUrl(uri, query); + const headers: Record = {}; + const params: RequestInit = { method }; // Save token in memory and pass it into headers in case if storing cookies is disabled if (activeJwtToken) { @@ -38,13 +56,18 @@ const fetcher = METHODS.reduce((acc, method) => { } headers[XSRF_HEADER] = getCookie(XSRF_COOKIE) || ''; - if (json) { + 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'; - fetchParams.body = JSON.stringify(json); + params.body = JSON.stringify(body); + } else { + params.body = body; } try { - const res = await fetch(url, { ...fetchParams, method, headers }); + 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); @@ -93,6 +116,6 @@ const fetcher = METHODS.reduce((acc, method) => { } }; return acc; -}, {} as FetcherObject); +}, {} as FetcherMethods); export default fetcher;