From ec18080e3191f99a88e9ddec174c9f953d9e3a41 Mon Sep 17 00:00:00 2001 From: Hunter Craft <118154470+hunterckx@users.noreply.github.com> Date: Thu, 17 Oct 2024 20:12:23 -0700 Subject: [PATCH] feat!: use ky with limited retries instead of axios (#203) (#216) --- jest.config.js | 2 +- package-lock.json | 153 ++++++++++++++----------- package.json | 5 +- src/components/TempError/tempError.tsx | 19 +-- src/config/entities.ts | 1 - src/entity/api/service.ts | 51 ++++----- src/entity/common/client.ts | 62 ++++------ src/entity/common/service.ts | 18 ++- src/entity/common/utils.ts | 10 +- src/shared/utils.ts | 9 -- tests/authentication.test.ts | 8 +- tests/azulFileDownload.test.tsx | 12 +- tests/fetchApi.test.ts | 93 +++++++++++++++ tests/tsconfig.json | 1 + tests/useFileLocation.test.ts | 8 +- 15 files changed, 276 insertions(+), 176 deletions(-) create mode 100644 tests/fetchApi.test.ts diff --git a/jest.config.js b/jest.config.js index 7d5d4e8f..22055d14 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,5 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { - preset: "ts-jest", + preset: "ts-jest/presets/js-with-ts-esm", testEnvironment: "jest-environment-jsdom", }; diff --git a/package-lock.json b/package-lock.json index 87ccf710..3cc44e09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "husky": "^8.0.3", "jest": "^29.4.1", "jest-environment-jsdom": "^29.4.1", + "jest-fetch-mock": "^3.0.3", "prettier": "^2.8.3", "prettier-plugin-organize-imports": "^3.2.2", "storybook": "^7.6.17", @@ -60,9 +61,9 @@ "@mui/material": "^6.0.2", "@tanstack/react-table": "^8.19.2", "@tanstack/react-virtual": "^3.0.0-beta.59", - "axios": "^1.6.7", "copy-to-clipboard": "3.3.1", "isomorphic-dompurify": "0.24.0", + "ky": "^1.7.2", "next": "^14.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -10377,17 +10378,6 @@ "node": ">=4" } }, - "node_modules/axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", - "peer": true, - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -11883,9 +11873,9 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "dev": true, "engines": { "node": ">= 0.6" @@ -12015,6 +12005,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -14338,9 +14337,9 @@ } }, "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "dev": true, "dependencies": { "accepts": "~1.3.8", @@ -14348,7 +14347,7 @@ "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -14776,26 +14775,6 @@ "node": ">=0.4.0" } }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "peer": true, - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -17533,6 +17512,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "dependencies": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "node_modules/jest-get-type": { "version": "29.4.2", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.4.2.tgz", @@ -19067,6 +19056,18 @@ "node": ">= 8" } }, + "node_modules/ky": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.7.2.tgz", + "integrity": "sha512-OzIvbHKKDpi60TnF9t7UUVAF1B4mcqc02z5PIvrm08Wyb+yOcz63GRvEuVxNT18a9E1SrNouhB4W2NNLeD7Ykg==", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -22164,6 +22165,12 @@ "node": ">=0.4.0" } }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "dev": true + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -22208,7 +22215,8 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true }, "node_modules/psl": { "version": "1.9.0", @@ -33639,17 +33647,6 @@ "integrity": "sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==", "dev": true }, - "axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", - "peer": true, - "requires": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -34789,9 +34786,9 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "dev": true }, "cookie-signature": { @@ -34900,6 +34897,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "requires": { + "node-fetch": "^2.6.12" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -36622,9 +36628,9 @@ } }, "express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "dev": true, "requires": { "accepts": "~1.3.8", @@ -36632,7 +36638,7 @@ "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -37006,12 +37012,6 @@ "integrity": "sha512-WHRizzSrWFTcKo7cVcbP3wzZVhzsoYxoWqbnH4z+JXGqrjVmnsld6kBZWVlB200PwD5ur8r+HV3KUDxv3cHhOQ==", "dev": true }, - "follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "peer": true - }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -38923,6 +38923,16 @@ } } }, + "jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "requires": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "jest-get-type": { "version": "29.4.2", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.4.2.tgz", @@ -40060,6 +40070,12 @@ "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", "dev": true }, + "ky": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.7.2.tgz", + "integrity": "sha512-OzIvbHKKDpi60TnF9t7UUVAF1B4mcqc02z5PIvrm08Wyb+yOcz63GRvEuVxNT18a9E1SrNouhB4W2NNLeD7Ykg==", + "peer": true + }, "language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -42261,6 +42277,12 @@ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true }, + "promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "dev": true + }, "prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -42301,7 +42323,8 @@ "proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true }, "psl": { "version": "1.9.0", diff --git a/package.json b/package.json index d292d078..49b37fc5 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "12.0.0", "description": "", "scripts": { - "test": "jest", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "lint": "eslint .", "check-format": "prettier --check .", "storybook": "storybook dev -p 6006", @@ -57,6 +57,7 @@ "husky": "^8.0.3", "jest": "^29.4.1", "jest-environment-jsdom": "^29.4.1", + "jest-fetch-mock": "^3.0.3", "prettier": "^2.8.3", "prettier-plugin-organize-imports": "^3.2.2", "storybook": "^7.6.17", @@ -70,9 +71,9 @@ "@mui/material": "^6.0.2", "@tanstack/react-table": "^8.19.2", "@tanstack/react-virtual": "^3.0.0-beta.59", - "axios": "^1.6.7", "copy-to-clipboard": "3.3.1", "isomorphic-dompurify": "0.24.0", + "ky": "^1.7.2", "next": "^14.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/components/TempError/tempError.tsx b/src/components/TempError/tempError.tsx index 477807ec..8bfd1149 100644 --- a/src/components/TempError/tempError.tsx +++ b/src/components/TempError/tempError.tsx @@ -1,19 +1,20 @@ -import { AxiosError, isAxiosError } from "axios"; +import { HTTPError } from "ky"; import React from "react"; import { ErrorBox } from "./components/errorBox"; interface TempErrorProps { - error: Error | AxiosError; + error: Error | HTTPError; } export const TempError = ({ error }: TempErrorProps): JSX.Element => { - const { code, request } = isAxiosError(error) - ? { - ...error, - code: error.response?.status, - request: error.request.responseURL, - } - : { ...error, code: null, request: null }; + const { code, request } = + error instanceof HTTPError + ? { + ...error, + code: error.response.status, + request: error.response.url, + } + : { ...error, code: null, request: null }; return (
diff --git a/src/config/entities.ts b/src/config/entities.ts index 6b435593..0f55d952 100644 --- a/src/config/entities.ts +++ b/src/config/entities.ts @@ -139,7 +139,6 @@ export interface DataSourceConfig { defaultParams?: { catalog: string; }; - entityURL?: string; url: string; } diff --git a/src/entity/api/service.ts b/src/entity/api/service.ts index c70ab94f..5a570313 100644 --- a/src/entity/api/service.ts +++ b/src/entity/api/service.ts @@ -16,12 +16,11 @@ import { FilterState } from "../../hooks/useCategoryFilter"; import { getDefaultDetailParams, getDefaultListParams, - getEntityURL, } from "../../shared/utils"; import { convertUrlParams } from "../../utils/url"; -import { api } from "../common/client"; +import { fetchApi } from "../common/client"; import { fetchEntitiesFromURL } from "../common/service"; -import { getAxiosRequestOptions } from "../common/utils"; +import { getKyRequestOptions } from "../common/utils"; /** * Make a GET or POST request for a list of entities @@ -70,9 +69,9 @@ export const fetchAllEntities = async ( let hits = result.hits; let nextPage = result.pagination.next; while (nextPage) { - const { data: nextPageJson } = await api().get( - nextPage - ); + const nextPageJson = await ( + await fetchApi(nextPage) + ).json(); nextPage = nextPageJson.pagination.next; hits = [...hits, ...nextPageJson.hits]; } @@ -84,8 +83,8 @@ export const fetchAllEntities = async ( * @returns name of the default catalog and all available catalogs. */ export const fetchCatalog = async (): Promise => { - const res = await api().get(APIEndpoints.CATALOGS); - return res.data; + const res = await fetchApi(APIEndpoints.CATALOGS); + return await res.json(); }; /** @@ -108,18 +107,16 @@ export const fetchEntityDetail = async ( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- this response type can't be determined beforehand ): Promise => { const catalogParam = catalog ? { [AZUL_PARAM.CATALOG]: catalog } : undefined; - const options = getAxiosRequestOptions(accessToken); - const baseURL = getEntityURL(); - return await api(baseURL) - .get( - `${apiPath}/${id}?${convertUrlParams({ - ...defaultParams, - ...catalogParam, - })}`, - options - ) + const options = getKyRequestOptions(accessToken); + return await fetchApi( + `${apiPath}/${id}?${convertUrlParams({ + ...defaultParams, + ...catalogParam, + })}`, + options + ) .then((res) => { - return res.data; + return res.json(); }) .catch((error) => { if (swallow404) { @@ -162,12 +159,12 @@ export const fetchSummary = async ( }; } - const options = getAxiosRequestOptions(accessToken); - const res = await api().get( + const options = getKyRequestOptions(accessToken); + const res = await fetchApi( `${apiPath}?${convertUrlParams({ ...summaryParams })}`, options ); - return res.data; + return await res.json(); }; /** @@ -180,11 +177,11 @@ export const fetchSummaryFromURL = async ( path: string, accessToken: string | undefined ): Promise => { - const res = await api().get( + const res = await fetchApi( path, - getAxiosRequestOptions(accessToken) + getKyRequestOptions(accessToken) ); - return res.data; + return await res.json(); }; /** @@ -193,6 +190,6 @@ export const fetchSummaryFromURL = async ( * @returns system status. */ export const fetchSystemStatusFromURL = async (URL: string): Promise => { - const res = await api().get(URL); - return res.data; + const res = await fetchApi(URL); + return await res.json(); }; diff --git a/src/entity/common/client.ts b/src/entity/common/client.ts index 1e065b7a..c35000b6 100644 --- a/src/entity/common/client.ts +++ b/src/entity/common/client.ts @@ -1,48 +1,30 @@ -import axios, { - AxiosError, - AxiosInstance, - AxiosResponse, - HttpStatusCode, -} from "axios"; +import ky, { KyInstance, Options, ResponsePromise } from "ky"; import { getURL } from "../../shared/utils"; -let axiosInstance: AxiosInstance | null = null; +let kyInstance: KyInstance | null = null; /** - * Adding response interceptors to axios instances. - * @param api - AxiosInstance. + * Makes an HTTP request with the API URL as a base. + * @param url - URL to fetch. + * @param options - Ky options. + * @returns Ky response. */ -export const configureInterceptors = (api: AxiosInstance): void => { - api.interceptors.response.use( - (response: AxiosResponse) => response, - (error: AxiosError) => { - const { config, response } = error; - - if (response?.status === HttpStatusCode.ServiceUnavailable && config) { - const retryAfterValue = response.headers["Retry-After"]; - const waitingTime = retryAfterValue ? +retryAfterValue : 0; - return new Promise((resolve) => { - setTimeout(() => resolve(api(config)), waitingTime); - }); - } else { - return Promise.reject(error); - } - } - ); -}; - -/** - * Returns a singleton Axios instance configured for making HTTP requests to a specified base URL. - * @param baseURL - The base URL to use for the AxiosInstance. - * @returns axios instance. - */ -export const api = (baseURL = getURL()): AxiosInstance => { - if (!axiosInstance) { - axiosInstance = axios.create({ - baseURL, +export function fetchApi( + url: string, + options: Options = {} +): ResponsePromise { + if (!kyInstance) { + kyInstance = ky.create({ + prefixUrl: getURL(), + retry: { + delay: (attemptCount) => 1000 * 3 ** (attemptCount - 1), + limit: 3, + }, timeout: 20 * 1000, }); - configureInterceptors(axiosInstance); } - return axiosInstance; -}; + // If a full URL is provided, there shouldn't be a prefix URL. Otherwise, Ky requires that the URL not start with a slash. + if (/^https?:\/\//.test(url)) options = { prefixUrl: "", ...options }; + else url = url.replace(/^\//, ""); + return kyInstance.get(url, options); +} diff --git a/src/entity/common/service.ts b/src/entity/common/service.ts index 37460f0d..bb666427 100644 --- a/src/entity/common/service.ts +++ b/src/entity/common/service.ts @@ -2,9 +2,8 @@ import { AzulEntitiesResponse, AzulEntityStaticResponse, } from "../../apis/azul/common/entities"; -import { getEntityURL } from "../../shared/utils"; -import { api } from "./client"; -import { getAxiosRequestOptions } from "./utils"; +import { fetchApi } from "./client"; +import { getKyRequestOptions } from "./utils"; /** * Fetch entities from the given URL. @@ -16,11 +15,11 @@ export const fetchEntitiesFromURL = async ( URL: string, accessToken?: string ): Promise => { - const res = await api().get( + const res = await fetchApi( URL, - getAxiosRequestOptions(accessToken) + getKyRequestOptions(accessToken) ); - return res.data; + return await res.json(); }; /** @@ -33,10 +32,9 @@ export const fetchEntityFromURL = async ( URL: string, accessToken?: string ): Promise => { - const baseURL = getEntityURL(); - const res = await api(baseURL).get( + const res = await fetchApi( URL, - getAxiosRequestOptions(accessToken) + getKyRequestOptions(accessToken) ); - return res.data; + return await res.json(); }; diff --git a/src/entity/common/utils.ts b/src/entity/common/utils.ts index 5179c788..9927d73b 100644 --- a/src/entity/common/utils.ts +++ b/src/entity/common/utils.ts @@ -1,13 +1,11 @@ -import { AxiosRequestConfig } from "axios"; +import { Options } from "ky"; /** - * Returns Axios request configuration. + * Returns Ky request configuration. * @param accessToken - Access token. - * @returns Axios request configuration. + * @returns Ky request configuration. */ -export function getAxiosRequestOptions( - accessToken: string | undefined -): AxiosRequestConfig { +export function getKyRequestOptions(accessToken: string | undefined): Options { return { headers: accessToken ? { Authorization: "Bearer " + accessToken } : {}, }; diff --git a/src/shared/utils.ts b/src/shared/utils.ts index e7e6b5a3..12fabc14 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -25,15 +25,6 @@ export function getDefaultListParams(): return { ...defaultListParams, ...defaultParams }; } -/** - * Returns entity source URL. - * @returns entity source URL. - */ -export function getEntityURL(): string { - const dataSource = getConfig().dataSource; - return dataSource.entityURL || dataSource.url; -} - /** * Returns application URL. * @returns application url. diff --git a/tests/authentication.test.ts b/tests/authentication.test.ts index df08ccc7..358f9d63 100644 --- a/tests/authentication.test.ts +++ b/tests/authentication.test.ts @@ -1,3 +1,4 @@ +import { jest } from "@jest/globals"; import { LOGIN_STATUS_NOT_STARTED } from "../src/hooks/useAuthentication/common/constants"; import { LoginStatus, @@ -6,7 +7,12 @@ import { import { GoogleResponse } from "../src/hooks/useAuthentication/useFetchGoogleProfile"; import { TerraResponse } from "../src/hooks/useAuthentication/useFetchTerraProfile"; import { TerraTermsOfServiceResponse } from "../src/hooks/useAuthentication/useFetchTerraTermsOfService"; -import { shouldReleaseToken } from "../src/providers/authentication"; + +jest.unstable_mockModule("react-idle-timer", () => ({ + useIdleTimer: jest.fn(), +})); + +const { shouldReleaseToken } = await import("../src/providers/authentication"); describe("authentication", () => { // Boolean constants. diff --git a/tests/azulFileDownload.test.tsx b/tests/azulFileDownload.test.tsx index 649a72d4..312cc03e 100644 --- a/tests/azulFileDownload.test.tsx +++ b/tests/azulFileDownload.test.tsx @@ -1,15 +1,21 @@ +import { jest } from "@jest/globals"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import React from "react"; -import { AzulFileDownload } from "../src/components/Index/components/AzulFileDownload/azulFileDownload"; import { AZUL_FILE_DOWNLOAD_TEST_ID, AZUL_FILE_REQUEST_DOWNLOAD_PENDING_TEST_ID, AZUL_FILE_REQUEST_DOWNLOAD_TEST_ID, } from "../src/components/Index/components/AzulFileDownload/common/constants"; -import { useFileLocation } from "../src/hooks/useFileLocation"; import { getAnchorEl, getButtonById } from "../src/utils/tests"; -jest.mock("../src/hooks/useFileLocation"); +jest.unstable_mockModule("../src/hooks/useFileLocation", () => ({ + useFileLocation: jest.fn(), +})); + +const { AzulFileDownload } = await import( + "../src/components/Index/components/AzulFileDownload/azulFileDownload" +); +const { useFileLocation } = await import("../src/hooks/useFileLocation"); describe("AzulFileDownload", () => { const FILE_URL = "https://example.com/storage/file"; diff --git a/tests/fetchApi.test.ts b/tests/fetchApi.test.ts new file mode 100644 index 00000000..9e719b59 --- /dev/null +++ b/tests/fetchApi.test.ts @@ -0,0 +1,93 @@ +import { jest } from "@jest/globals"; +import fetchMock from "jest-fetch-mock"; + +jest.unstable_mockModule("../src/shared/utils", () => ({ + getURL: (): string => "http://example.com", +})); + +const { fetchApi } = await import("../src/entity/common/client"); + +const actualSetTimeout = setTimeout; + +const mockSetTimeout = jest + .spyOn(globalThis, "setTimeout") + .mockImplementation(((callback: () => void, timeout: number) => { + if (timeout === 20000) actualSetTimeout(callback, timeout); + else callback(); + }) as typeof setTimeout); + +beforeAll(async () => { + fetchMock.doMock(); + globalThis.fetch = ((...args) => { + return fetchMock(...(args as Parameters)); + }) as typeof fetch; + AbortSignal.prototype.throwIfAborted = function (): void { + if (this.aborted) throw this.reason; + }; +}); + +describe("api", () => { + it("returns immediately-successful response", async () => { + fetchMock.mockResponseOnce("immediately-successful"); + const res = await fetchApi(""); + expect(res.status).toBe(200); + expect(await res.text()).toBe("immediately-successful"); + }); + + it("returns response after backing off from three 503 responses", async () => { + mockSetTimeout.mockClear(); + + fetchMock.mockResponseOnce("", { status: 503 }); + fetchMock.mockResponseOnce("", { status: 503 }); + fetchMock.mockResponseOnce("", { status: 503 }); + fetchMock.mockResponseOnce("after-three-503"); + const res = await fetchApi(""); + expect(res.status).toBe(200); + expect(await res.text()).toBe("after-three-503"); + + expect(mockSetTimeout).toBeCalledTimes(7); + expect(mockSetTimeout.mock.calls[1][1]).toBe(1000); + expect(mockSetTimeout.mock.calls[3][1]).toBe(3000); + expect(mockSetTimeout.mock.calls[5][1]).toBe(9000); + }); + + it("throws error after four 503 responses", async () => { + mockSetTimeout.mockClear(); + + fetchMock.mockResponseOnce("", { status: 503 }); + fetchMock.mockResponseOnce("", { status: 503 }); + fetchMock.mockResponseOnce("", { status: 503 }); + fetchMock.mockResponseOnce("", { status: 503 }); + const error = await fetchApi("").catch((e) => e); + expect(error).toMatchObject({ response: { status: 503 } }); + + expect(mockSetTimeout).toBeCalledTimes(7); + }); + + it("throws error after one 503 response and one 400 response", async () => { + mockSetTimeout.mockClear(); + + fetchMock.mockResponseOnce("", { status: 503 }); + fetchMock.mockResponseOnce("", { status: 400 }); + const error = await fetchApi("").catch((e) => e); + expect(error).toMatchObject({ response: { status: 400 } }); + + expect(mockSetTimeout).toBeCalledTimes(3); + }); + + it("returns response after delaying based on Retry-After", async () => { + mockSetTimeout.mockClear(); + + fetchMock.mockResponseOnce("", { + headers: { "Retry-After": "7" }, + status: 503, + }); + fetchMock.mockResponseOnce("after-retry-after"); + const res = await fetchApi(""); + expect(res.status).toBe(200); + expect(await res.text()).toBe("after-retry-after"); + + expect(mockSetTimeout).toBeCalledTimes(3); + expect(mockSetTimeout.mock.calls[1][1]).toBe(7000); + }); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 57fa38e0..f67c35d5 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,4 +1,5 @@ { + "allowJs": true, "compilerOptions": { "rootDir": "../" }, diff --git a/tests/useFileLocation.test.ts b/tests/useFileLocation.test.ts index bb21992f..329c5e75 100644 --- a/tests/useFileLocation.test.ts +++ b/tests/useFileLocation.test.ts @@ -1,10 +1,14 @@ -import { buildFetchFileUrl } from "../src/hooks/useFileLocation"; +import { jest } from "@jest/globals"; const URL_INVALID = "invalidUrl"; const URL_WITH_FETCH_PREPEND = "https://example.com/fetch/repository/file"; const URL_WITHOUT_FETCH_PREPEND = "https://example.com/repository/file"; -jest.mock("../src/hooks/useRequestFileLocation"); +jest.unstable_mockModule("../src/hooks/useRequestFileLocation", () => ({ + useRequestFileLocation: jest.fn(), +})); + +const { buildFetchFileUrl } = await import("../src/hooks/useFileLocation"); describe("useFileLocation", () => { describe("build file URL", () => {