diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 180fd320..a837287f 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -47,6 +47,9 @@ module.exports = { '@typescript-eslint/no-explicit-any': 0, '@typescript-eslint/no-object-literal-type-assertion': 0, }, + globals: { + fail: true, + }, }, { files: ['*.test.ts', '*.test.tsx', '*.test.js', '*.test.jsx'], diff --git a/web/app/common/fetcher.test.ts b/web/app/common/fetcher.test.ts new file mode 100644 index 00000000..8d1e54f2 --- /dev/null +++ b/web/app/common/fetcher.test.ts @@ -0,0 +1,96 @@ +import fetcher from './fetcher'; + +describe('fetcher', () => { + let originalHeaders = (window as any).Headers; + + beforeAll(() => { + originalHeaders = (window as any).Headers; + (window as any).Headers = class { + append() {} + has() { + return false; + } + get() { + return null; + } + }; + }); + + afterAll(() => { + (window as any).Headers = originalHeaders; + }); + + afterEach(() => { + (window.fetch as any).mockRestore(); + }); + + describe('errors', () => { + it('should throw json on api json response with >= 400 status code', async () => { + const response = { + code: 2, + error: 'you just cant', + details: 'you just cant at all', + }; + (window.fetch as any) = jest.fn().mockImplementation(async () => ({ + status: 400, + headers: new (window as any).Headers(), + json: async () => response, + text: async () => JSON.stringify(response), + })); + + return fetcher + .get('/api/some') + .then(data => { + fail(data); + }) + .catch(e => { + expect(e.code).toBe(2); + expect(e.error).toBe('you just cant'); + expect(e.details).toBe('you just cant at all'); + }); + }); + it('should throw special error object on 401 status', async () => { + const response = 'unauthorized nginx response'; + (window.fetch as any) = jest.fn().mockImplementation(async () => ({ + status: 401, + headers: new (window as any).Headers(), + json: async () => { + throw new Error('json parse error'); + }, + text: async () => response, + })); + + return fetcher + .get('/api/some') + .then(data => { + fail(data); + }) + .catch(e => { + expect(e.code).toBe(-1); + expect(e.error).toBe('Not authorized.'); + expect(e.details).toBe('Not authorized.'); + }); + }); + it('should throw "Something went wrong." object on unknown status', async () => { + (jest.spyOn(window, 'fetch') as any).mockImplementation(async () => ({ + status: 400, + headers: new (window as any).Headers(), + json: async () => { + throw new Error('json parse error'); + }, + text: async () => 'you given me something wrong', + })); + + return fetcher + .get({ url: '/api/some', logError: false }) + .then(data => { + fail(data); + }) + .catch(e => { + expect(e.code).toBe(-1); + expect(e.error).toBe('Something went wrong.'); + expect(e.details).toBe('you given me something wrong'); + }); + }); + }); +}); diff --git a/web/app/common/fetcher.ts b/web/app/common/fetcher.ts index 685467e7..8f0fe962 100644 --- a/web/app/common/fetcher.ts +++ b/web/app/common/fetcher.ts @@ -2,6 +2,7 @@ import { BASE_URL, API_BASE } from './constants'; import { siteId } from './settings'; import { StaticStore } from './static_store'; import { getCookie } from './cookies'; +import { httpErrorMap } from '@app/utils/errorUtils'; export type FetcherMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head'; const methods: FetcherMethod[] = ['get', 'post', 'put', 'patch', 'delete', 'head']; @@ -79,6 +80,14 @@ const fetcher = methods.reduce>((acc, method) => { StaticStore.serverClientTimeDiff = timeDiff; if (res.status >= 400) { + if (httpErrorMap.has(res.status)) { + const errString = httpErrorMap.get(res.status)!; + throw { + code: -1, + error: errString, + details: errString, + }; + } return res.text().then(text => { let err; try { @@ -88,7 +97,11 @@ const fetcher = methods.reduce>((acc, method) => { // eslint-disable-next-line no-console console.error(err); } - throw 'Something went wrong.'; + throw { + code: -1, + error: 'Something went wrong.', + details: text, + }; } throw err; }); diff --git a/web/app/utils/errorUtils.ts b/web/app/utils/errorUtils.ts index 4b85e081..9c7efa9a 100644 --- a/web/app/utils/errorUtils.ts +++ b/web/app/utils/errorUtils.ts @@ -1,3 +1,7 @@ +/** + * map of codes that server returns in its response in case of error + * to client readable version + */ const errorMessageForCodes = new Map([ [0, 'Something went wrong. Please try again a bit later.'], [1, 'Comment cannot be found. Please refresh the page and try again.'], @@ -20,9 +24,23 @@ const errorMessageForCodes = new Map([ [18, 'Requested file cannot be found.'], ]); +/** + * map of http rest codes to ui label, used by fetcher to generate error with `-1` code + */ +export const httpErrorMap = new Map([ + [401, 'Not authorized.'], + [403, 'Forbidden.'], + [429, 'You have reached maximum request limit.'], +]); + export type FetcherError = | string | { + /** + * Error code, that is part of server error response. + * + * Note that -1 is reserved for error where `error` field shall be used directly + */ code?: number; details?: string; error: string; @@ -38,6 +56,10 @@ export function extractErrorMessageFromResponse(response: FetcherError): string return response; } + if (response.code === -1) { + return response.error; + } + if (typeof response.code === 'number' && errorMessageForCodes.has(response.code)) { return errorMessageForCodes.get(response.code)!; }