From 4ef493d785936747186601a93d787379d84bca8c Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Thu, 19 Sep 2024 12:48:34 +0530 Subject: [PATCH 1/8] refactor api and add tests for interceptors --- .../packages/utils/src/compose/compose.ts | 4 + .../packages/utils/src/compose/index.ts | 1 + app/client/packages/utils/src/index.ts | 1 + app/client/src/api/Api.ts | 23 +- .../src/api/ConsolidatedPageLoadApi.tsx | 26 -- .../apiFailureResponseInterceptors.ts | 221 ++++++++++++++ .../api/__tests__/apiRequestInterceptors.ts | 121 ++++++++ .../apiSucessResponseInterceptors.ts | 40 +++ app/client/src/api/core/api.ts | 28 ++ app/client/src/api/core/factory.ts | 17 ++ app/client/src/api/core/index.ts | 2 + .../api/helpers/addExecutionMetaProperties.ts | 12 + app/client/src/api/helpers/index.ts | 3 + app/client/src/api/helpers/is404orAuthPath.ts | 5 + .../api/helpers/validateJsonResponseMeta.ts | 14 + app/client/src/api/index.ts | 1 + app/client/src/api/interceptors/index.ts | 2 + .../request/addAnonymousUserIdHeader.ts | 16 + .../request/addEnvironmentHeader.ts | 19 ++ .../request/addGitBranchHeader.ts | 16 + .../addPerformanceMonitoringHeaders.ts | 10 + .../request/addRequestedByHeader.ts | 13 + .../request/apiRequestInterceptor.ts | 64 ++++ .../request/blockAirgappedRoutes.ts | 29 ++ .../request/increaseGitApiTimeout.ts | 9 + .../src/api/interceptors/request/index.ts | 8 + .../response/apiFailureResponseInterceptor.ts | 29 ++ .../response/apiSuccessResponseInterceptor.ts | 16 + .../failureHandlers/handle413Error.ts | 25 ++ .../failureHandlers/handleCancelError.ts | 11 + .../handleExecuteActionError.ts | 14 + .../handleMissingResponseMeta.ts | 17 ++ .../failureHandlers/handleNotFoundError.ts | 34 +++ .../failureHandlers/handleOfflineError.ts | 13 + .../failureHandlers/handleServerError.ts | 15 + .../failureHandlers/handleTimeoutError.ts | 22 ++ .../handleUnauthorizedError.ts | 34 +++ .../response/failureHandlers/index.ts | 9 + .../src/api/interceptors/response/index.ts | 2 + .../src/api/services/AppThemingApi/api.ts | 38 +++ .../src/api/services/AppThemingApi/index.ts | 1 + .../services/ConsolidatedPageLoadApi/api.ts | 20 ++ .../services/ConsolidatedPageLoadApi/index.ts | 1 + app/client/src/api/services/index.ts | 2 + app/client/src/api/types.ts | 24 ++ app/client/src/ce/api/ApiUtils.ts | 282 ------------------ app/client/src/ce/constants/ApiConstants.tsx | 44 +++ app/client/src/ce/sagas/PageSagas.tsx | 2 +- app/client/src/sagas/AppThemingSaga.tsx | 43 +-- app/client/src/sagas/InitSagas.ts | 6 +- 50 files changed, 1060 insertions(+), 349 deletions(-) create mode 100644 app/client/packages/utils/src/compose/compose.ts create mode 100644 app/client/packages/utils/src/compose/index.ts delete mode 100644 app/client/src/api/ConsolidatedPageLoadApi.tsx create mode 100644 app/client/src/api/__tests__/apiFailureResponseInterceptors.ts create mode 100644 app/client/src/api/__tests__/apiRequestInterceptors.ts create mode 100644 app/client/src/api/__tests__/apiSucessResponseInterceptors.ts create mode 100644 app/client/src/api/core/api.ts create mode 100644 app/client/src/api/core/factory.ts create mode 100644 app/client/src/api/core/index.ts create mode 100644 app/client/src/api/helpers/addExecutionMetaProperties.ts create mode 100644 app/client/src/api/helpers/index.ts create mode 100644 app/client/src/api/helpers/is404orAuthPath.ts create mode 100644 app/client/src/api/helpers/validateJsonResponseMeta.ts create mode 100644 app/client/src/api/index.ts create mode 100644 app/client/src/api/interceptors/index.ts create mode 100644 app/client/src/api/interceptors/request/addAnonymousUserIdHeader.ts create mode 100644 app/client/src/api/interceptors/request/addEnvironmentHeader.ts create mode 100644 app/client/src/api/interceptors/request/addGitBranchHeader.ts create mode 100644 app/client/src/api/interceptors/request/addPerformanceMonitoringHeaders.ts create mode 100644 app/client/src/api/interceptors/request/addRequestedByHeader.ts create mode 100644 app/client/src/api/interceptors/request/apiRequestInterceptor.ts create mode 100644 app/client/src/api/interceptors/request/blockAirgappedRoutes.ts create mode 100644 app/client/src/api/interceptors/request/increaseGitApiTimeout.ts create mode 100644 app/client/src/api/interceptors/request/index.ts create mode 100644 app/client/src/api/interceptors/response/apiFailureResponseInterceptor.ts create mode 100644 app/client/src/api/interceptors/response/apiSuccessResponseInterceptor.ts create mode 100644 app/client/src/api/interceptors/response/failureHandlers/handle413Error.ts create mode 100644 app/client/src/api/interceptors/response/failureHandlers/handleCancelError.ts create mode 100644 app/client/src/api/interceptors/response/failureHandlers/handleExecuteActionError.ts create mode 100644 app/client/src/api/interceptors/response/failureHandlers/handleMissingResponseMeta.ts create mode 100644 app/client/src/api/interceptors/response/failureHandlers/handleNotFoundError.ts create mode 100644 app/client/src/api/interceptors/response/failureHandlers/handleOfflineError.ts create mode 100644 app/client/src/api/interceptors/response/failureHandlers/handleServerError.ts create mode 100644 app/client/src/api/interceptors/response/failureHandlers/handleTimeoutError.ts create mode 100644 app/client/src/api/interceptors/response/failureHandlers/handleUnauthorizedError.ts create mode 100644 app/client/src/api/interceptors/response/failureHandlers/index.ts create mode 100644 app/client/src/api/interceptors/response/index.ts create mode 100644 app/client/src/api/services/AppThemingApi/api.ts create mode 100644 app/client/src/api/services/AppThemingApi/index.ts create mode 100644 app/client/src/api/services/ConsolidatedPageLoadApi/api.ts create mode 100644 app/client/src/api/services/ConsolidatedPageLoadApi/index.ts create mode 100644 app/client/src/api/services/index.ts create mode 100644 app/client/src/api/types.ts diff --git a/app/client/packages/utils/src/compose/compose.ts b/app/client/packages/utils/src/compose/compose.ts new file mode 100644 index 000000000000..3fd17574c163 --- /dev/null +++ b/app/client/packages/utils/src/compose/compose.ts @@ -0,0 +1,4 @@ +export const compose = + (...fns: Array<(arg: T) => T>) => + (x: T) => + fns.reduce((acc, fn) => fn(acc), x); diff --git a/app/client/packages/utils/src/compose/index.ts b/app/client/packages/utils/src/compose/index.ts new file mode 100644 index 000000000000..cbcc7441207c --- /dev/null +++ b/app/client/packages/utils/src/compose/index.ts @@ -0,0 +1 @@ +export { compose } from "./compose"; diff --git a/app/client/packages/utils/src/index.ts b/app/client/packages/utils/src/index.ts index 7ea2e7134f23..442a65511ef1 100644 --- a/app/client/packages/utils/src/index.ts +++ b/app/client/packages/utils/src/index.ts @@ -1 +1,2 @@ export * from "./object"; +export * from "./compose"; diff --git a/app/client/src/api/Api.ts b/app/client/src/api/Api.ts index 206f38a1f55a..1eff364c4d04 100644 --- a/app/client/src/api/Api.ts +++ b/app/client/src/api/Api.ts @@ -1,15 +1,13 @@ -import type { AxiosInstance, AxiosRequestConfig } from "axios"; import axios from "axios"; -import { REQUEST_TIMEOUT_MS } from "ee/constants/ApiConstants"; -import { convertObjectToQueryParams } from "utils/URLUtils"; +import type { AxiosInstance, AxiosRequestConfig } from "axios"; import { - apiFailureResponseInterceptor, apiRequestInterceptor, + apiFailureResponseInterceptor, apiSuccessResponseInterceptor, - blockedApiRoutesForAirgapInterceptor, -} from "ee/api/ApiUtils"; +} from "./interceptors"; +import { REQUEST_TIMEOUT_MS } from "ee/constants/ApiConstants"; +import { convertObjectToQueryParams } from "utils/URLUtils"; -//TODO(abhinav): Refactor this to make more composable. export const apiRequestConfig = { baseURL: "/api/", timeout: REQUEST_TIMEOUT_MS, @@ -21,16 +19,7 @@ export const apiRequestConfig = { const axiosInstance: AxiosInstance = axios.create(); -const requestInterceptors = [ - blockedApiRoutesForAirgapInterceptor, - apiRequestInterceptor, -]; - -requestInterceptors.forEach((interceptor) => { - // TODO: Fix this the next time the file is edited - // eslint-disable-next-line @typescript-eslint/no-explicit-any - axiosInstance.interceptors.request.use(interceptor as any); -}); +axiosInstance.interceptors.request.use(apiRequestInterceptor); axiosInstance.interceptors.response.use( apiSuccessResponseInterceptor, diff --git a/app/client/src/api/ConsolidatedPageLoadApi.tsx b/app/client/src/api/ConsolidatedPageLoadApi.tsx deleted file mode 100644 index 473b9a093015..000000000000 --- a/app/client/src/api/ConsolidatedPageLoadApi.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import Api from "./Api"; -import type { AxiosPromise } from "axios"; -import type { ApiResponse } from "api/ApiResponses"; - -import type { InitConsolidatedApi } from "sagas/InitSagas"; - -class ConsolidatedPageLoadApi extends Api { - static url = "v1/consolidated-api"; - static consolidatedApiViewUrl = `${ConsolidatedPageLoadApi.url}/view`; - static consolidatedApiEditUrl = `${ConsolidatedPageLoadApi.url}/edit`; - - static async getConsolidatedPageLoadDataView(params: { - applicationId?: string; - defaultPageId?: string; - }): Promise>> { - return Api.get(ConsolidatedPageLoadApi.consolidatedApiViewUrl, params); - } - static async getConsolidatedPageLoadDataEdit(params: { - applicationId?: string; - defaultPageId?: string; - }): Promise>> { - return Api.get(ConsolidatedPageLoadApi.consolidatedApiEditUrl, params); - } -} - -export default ConsolidatedPageLoadApi; diff --git a/app/client/src/api/__tests__/apiFailureResponseInterceptors.ts b/app/client/src/api/__tests__/apiFailureResponseInterceptors.ts new file mode 100644 index 000000000000..44d765ff2b52 --- /dev/null +++ b/app/client/src/api/__tests__/apiFailureResponseInterceptors.ts @@ -0,0 +1,221 @@ +import axios from "axios"; +import type { AxiosError } from "axios"; + +import { + apiFailureResponseInterceptor, + apiSuccessResponseInterceptor, +} from "api/interceptors"; +import type { ApiResponse } from "api/types"; +import { + createMessage, + ERROR_0, + ERROR_500, + SERVER_API_TIMEOUT_ERROR, +} from "ee/constants/messages"; +import { ERROR_CODES } from "ee/constants/ApiConstants"; +import { UserCancelledActionExecutionError } from "sagas/ActionExecution/errorUtils"; + +describe("Api success response interceptors", () => { + beforeAll(() => { + axios.interceptors.response.use( + apiSuccessResponseInterceptor, + apiFailureResponseInterceptor, + ); + }); + + it("checks 413 error", async () => { + axios.defaults.adapter = async () => { + return new Promise((resolve, reject) => { + reject({ + response: { + status: 413, + statusText: "Request Entity Too Large", + }, + } as AxiosError); + }); + }; + + try { + await axios.get("https://example.com"); + } catch (error) { + expect((error as AxiosError).response?.status).toBe(413); + } + + axios.defaults.adapter = undefined; + }); + + it("checks the response message when request is made when user is offline", async () => { + const onlineGetter: jest.SpyInstance = jest.spyOn( + window.navigator, + "onLine", + "get", + ); + + onlineGetter.mockReturnValue(false); + axios.defaults.adapter = async () => { + return new Promise((resolve, reject) => { + reject({ + message: "Network Error", + } as AxiosError); + }); + }; + + try { + await axios.get("https://example.com"); + } catch (error) { + expect((error as AxiosError).message).toBe( + createMessage(ERROR_0), + ); + } + + onlineGetter.mockRestore(); + axios.defaults.adapter = undefined; + }); + + it("checks if it throws UserCancelledActionExecutionError user cancels the request ", async () => { + const cancelToken = axios.CancelToken.source(); + + axios.defaults.adapter = async () => { + return new Promise((resolve, reject) => { + cancelToken.cancel("User cancelled the request"); + + reject({ + message: "User cancelled the request", + } as AxiosError); + }); + }; + + try { + await axios.get("https://example.com", { + cancelToken: cancelToken.token, + }); + } catch (error) { + expect(error).toBeInstanceOf(UserCancelledActionExecutionError); + } + + axios.defaults.adapter = undefined; + }); + + it("checks the response message when request fails for exeuction action urls", async () => { + axios.defaults.adapter = async () => { + return Promise.reject({ + response: { + status: 500, + statusText: "Internal Server Error", + headers: { + "content-length": 1, + }, + config: { + headers: { + timer: "1000", + }, + }, + }, + config: { + url: "/v1/actions/execute", + }, + }); + }; + + const url = "/v1/actions/execute"; + const response = await axios.get(url); + + expect(response).toHaveProperty("clientMeta"); + + axios.defaults.adapter = undefined; + }); + + it("checks the error response in case of timeout", async () => { + axios.defaults.adapter = async () => { + return Promise.reject({ + code: "ECONNABORTED", + message: "timeout of 1000ms exceeded", + }); + }; + + try { + await axios.get("https://example.com"); + } catch (error) { + expect((error as AxiosError).message).toBe( + createMessage(SERVER_API_TIMEOUT_ERROR), + ); + expect((error as AxiosError).code).toBe( + ERROR_CODES.REQUEST_TIMEOUT, + ); + } + + axios.defaults.adapter = undefined; + }); + + it("checks the error response in case of server error", async () => { + axios.defaults.adapter = async () => { + return Promise.reject({ + response: { + status: 502, + }, + }); + }; + + try { + await axios.get("https://example.com"); + } catch (error) { + expect((error as AxiosError).message).toBe( + createMessage(ERROR_500), + ); + expect((error as AxiosError).code).toBe( + ERROR_CODES.SERVER_ERROR, + ); + } + + axios.defaults.adapter = undefined; + }); + + it("checks error response in case of unauthorized error", async () => { + axios.defaults.adapter = async () => { + return Promise.reject({ + response: { + status: 401, + }, + }); + }; + + try { + await axios.get("https://example.com"); + } catch (error) { + expect((error as AxiosError).message).toBe( + "Unauthorized. Redirecting to login page...", + ); + expect((error as AxiosError).code).toBe( + ERROR_CODES.REQUEST_NOT_AUTHORISED, + ); + } + }); + + it("checks error response in case of not found error", async () => { + axios.defaults.adapter = async () => { + return Promise.reject({ + response: { + data: { + responseMeta: { + status: 404, + error: { + code: "AE-ACL-4004", + }, + }, + }, + }, + }); + }; + + try { + await axios.get("https://example.com"); + } catch (error) { + expect((error as AxiosError).message).toBe( + "Resource Not Found", + ); + expect((error as AxiosError).code).toBe( + ERROR_CODES.PAGE_NOT_FOUND, + ); + } + }); +}); diff --git a/app/client/src/api/__tests__/apiRequestInterceptors.ts b/app/client/src/api/__tests__/apiRequestInterceptors.ts new file mode 100644 index 000000000000..b69eeed5af65 --- /dev/null +++ b/app/client/src/api/__tests__/apiRequestInterceptors.ts @@ -0,0 +1,121 @@ +import axios from "axios"; +import { + addGitBranchHeader, + blockAirgappedRoutes, + addRequestedByHeader, + addEnvironmentHeader, + increaseGitApiTimeout, + addAnonymousUserIdHeader, + addPerformanceMonitoringHeaders, +} from "api/interceptors/request"; + +describe("Api request interceptors", () => { + beforeAll(() => { + axios.defaults.adapter = async (config) => { + return new Promise((resolve) => { + resolve({ + data: "Test data", + status: 200, + statusText: "OK", + headers: { + "content-length": 123, + "content-type": "application/json", + }, + config, + }); + }); + }; + }); + + it("checks if the request config has timer in the headers", async () => { + const url = "v1/actions/execute"; + const identifier = axios.interceptors.request.use( + addPerformanceMonitoringHeaders, + ); + const response = await axios.get(url); + + expect(response.config.headers).toHaveProperty("timer"); + + axios.interceptors.request.eject(identifier); + }); + + it("checks if the request config has anonymousUserId in the headers", async () => { + const url = "v1/actions/execute"; + const identifier = axios.interceptors.request.use((config) => + addAnonymousUserIdHeader(config, { + segmentEnabled: true, + anonymousId: "anonymousUserId", + }), + ); + const response = await axios.get(url); + + expect(response.config.headers).toHaveProperty("x-anonymous-user-id"); + expect(response.config.headers["x-anonymous-user-id"]).toBe( + "anonymousUserId", + ); + + axios.interceptors.request.eject(identifier); + }); + + it("checks if the request config has csrfToken in the headers", async () => { + const url = "v1/actions/execute"; + const identifier = axios.interceptors.request.use(addRequestedByHeader); + const response = await axios.post(url); + + expect(response.config.headers).toHaveProperty("X-Requested-By"); + expect(response.config.headers["X-Requested-By"]).toBe("Appsmith"); + + axios.interceptors.request.eject(identifier); + }); + + it("checks if the request config has gitBranch in the headers", async () => { + const url = "v1/"; + const identifier = axios.interceptors.request.use((config) => { + return addGitBranchHeader(config, { branch: "master" }); + }); + const response = await axios.get(url); + + expect(response.config.headers).toHaveProperty("branchName"); + expect(response.config.headers["branchName"]).toBe("master"); + + axios.interceptors.request.eject(identifier); + }); + + it("checks if the request config has environmentId in the headers", async () => { + const url = "v1/saas"; + const identifier = axios.interceptors.request.use((config) => { + return addEnvironmentHeader(config, { env: "default" }); + }); + + const response = await axios.get(url); + + expect(response.config.headers).toHaveProperty("X-Appsmith-EnvironmentId"); + expect(response.config.headers["X-Appsmith-EnvironmentId"]).toBe("default"); + + axios.interceptors.request.eject(identifier); + }); + + it("checks if the request config has airgapped in the headers", async () => { + const url = "v1/saas"; + const identifier = axios.interceptors.request.use((config) => { + return blockAirgappedRoutes(config, { isAirgapped: true }); + }); + const response = await axios.get(url); + + expect(response.data).toBeNull(); + expect(response.status).toBe(200); + expect(response.statusText).toBe("OK"); + + axios.interceptors.request.eject(identifier); + }); + + it("checks if the request config has a timeout of 120s", async () => { + const url = "v1/git/"; + const identifier = axios.interceptors.request.use(increaseGitApiTimeout); + const response = await axios.get(url); + + expect(response.config.timeout).toBe(120000); + + axios.interceptors.request.eject(identifier); + }); +}); diff --git a/app/client/src/api/__tests__/apiSucessResponseInterceptors.ts b/app/client/src/api/__tests__/apiSucessResponseInterceptors.ts new file mode 100644 index 000000000000..1b8963577d41 --- /dev/null +++ b/app/client/src/api/__tests__/apiSucessResponseInterceptors.ts @@ -0,0 +1,40 @@ +import axios from "axios"; +import { apiSuccessResponseInterceptor } from "api/interceptors"; + +describe("Api success response interceptors", () => { + beforeAll(() => { + axios.interceptors.response.use(apiSuccessResponseInterceptor); + axios.defaults.adapter = async (config) => { + return new Promise((resolve) => { + resolve({ + data: { + data: "Test data", + }, + status: 200, + statusText: "OK", + headers: { + "content-length": 123, + "content-type": "application/json", + }, + config, + }); + }); + }; + }); + + it("checks response for non-action-execution url", async () => { + const url = "/v1/sass"; + const response = await axios.get(url); + + expect(response.data).toBe("Test data"); + }); + + it("checks response for action-execution url", async () => { + const url = "/v1/actions/execute"; + const response = await axios.get(url); + + expect(response).toHaveProperty("data"); + expect(response.data).toBe("Test data"); + expect(response).toHaveProperty("clientMeta"); + }); +}); diff --git a/app/client/src/api/core/api.ts b/app/client/src/api/core/api.ts new file mode 100644 index 000000000000..6b81c64e9a05 --- /dev/null +++ b/app/client/src/api/core/api.ts @@ -0,0 +1,28 @@ +import type { AxiosResponseData } from "api/types"; + +import { apiFactory } from "./factory"; + +const apiInstance = apiFactory(); + +export async function get(...args: Parameters) { + return apiInstance.get>(...args); +} + +export async function post(...args: Parameters) { + return apiInstance.post>(...args); +} + +export async function put(...args: Parameters) { + return apiInstance.put>(...args); +} + +// Note: _delete is used instead of delete because delete is a reserved keyword in JavaScript +async function _delete(...args: Parameters) { + return apiInstance.delete>(...args); +} + +export { _delete as delete }; + +export async function patch(...args: Parameters) { + return apiInstance.patch>(...args); +} diff --git a/app/client/src/api/core/factory.ts b/app/client/src/api/core/factory.ts new file mode 100644 index 000000000000..b0b75ada3885 --- /dev/null +++ b/app/client/src/api/core/factory.ts @@ -0,0 +1,17 @@ +import axios from "axios"; +import { DEFAULT_AXIOS_CONFIG } from "ee/constants/ApiConstants"; +import { apiRequestInterceptor } from "api/interceptors/request/apiRequestInterceptor"; +import { apiSuccessResponseInterceptor } from "api/interceptors/response/apiSuccessResponseInterceptor"; +import { apiFailureResponseInterceptor } from "api/interceptors/response/apiFailureResponseInterceptor"; + +export function apiFactory() { + const axiosInstance = axios.create(DEFAULT_AXIOS_CONFIG); + + axiosInstance.interceptors.request.use(apiRequestInterceptor); + axiosInstance.interceptors.response.use( + apiSuccessResponseInterceptor, + apiFailureResponseInterceptor, + ); + + return axiosInstance; +} diff --git a/app/client/src/api/core/index.ts b/app/client/src/api/core/index.ts new file mode 100644 index 000000000000..ed3655362f50 --- /dev/null +++ b/app/client/src/api/core/index.ts @@ -0,0 +1,2 @@ +export * as api from "./api"; +export { apiFactory } from "./factory"; diff --git a/app/client/src/api/helpers/addExecutionMetaProperties.ts b/app/client/src/api/helpers/addExecutionMetaProperties.ts new file mode 100644 index 000000000000..e4fba5c6152e --- /dev/null +++ b/app/client/src/api/helpers/addExecutionMetaProperties.ts @@ -0,0 +1,12 @@ +import type { AxiosResponse } from "axios"; + +export const addExecutionMetaProperties = (response: AxiosResponse) => { + const clientMeta = { + size: response.headers["content-length"], + duration: Number( + performance.now() - response.config.headers.timer, + ).toFixed(), + }; + + return { ...response.data, clientMeta }; +}; diff --git a/app/client/src/api/helpers/index.ts b/app/client/src/api/helpers/index.ts new file mode 100644 index 000000000000..1cd96da7b8cb --- /dev/null +++ b/app/client/src/api/helpers/index.ts @@ -0,0 +1,3 @@ +export { is404orAuthPath } from "./is404orAuthPath"; +export { validateJsonResponseMeta } from "./validateJsonResponseMeta"; +export { addExecutionMetaProperties } from "./addExecutionMetaProperties"; diff --git a/app/client/src/api/helpers/is404orAuthPath.ts b/app/client/src/api/helpers/is404orAuthPath.ts new file mode 100644 index 000000000000..10d4193ebbc1 --- /dev/null +++ b/app/client/src/api/helpers/is404orAuthPath.ts @@ -0,0 +1,5 @@ +export const is404orAuthPath = () => { + const pathName = window.location.pathname; + + return /^\/404/.test(pathName) || /^\/user\/\w+/.test(pathName); +}; diff --git a/app/client/src/api/helpers/validateJsonResponseMeta.ts b/app/client/src/api/helpers/validateJsonResponseMeta.ts new file mode 100644 index 000000000000..de295660abdb --- /dev/null +++ b/app/client/src/api/helpers/validateJsonResponseMeta.ts @@ -0,0 +1,14 @@ +import * as Sentry from "@sentry/react"; +import type { AxiosResponse } from "axios"; +import { CONTENT_TYPE_HEADER_KEY } from "constants/ApiEditorConstants/CommonApiConstants"; + +export const validateJsonResponseMeta = (response: AxiosResponse) => { + if ( + response.headers[CONTENT_TYPE_HEADER_KEY] === "application/json" && + !response.data.responseMeta + ) { + Sentry.captureException(new Error("Api responded without response meta"), { + contexts: { response: response.data }, + }); + } +}; diff --git a/app/client/src/api/index.ts b/app/client/src/api/index.ts new file mode 100644 index 000000000000..b2221a94a89f --- /dev/null +++ b/app/client/src/api/index.ts @@ -0,0 +1 @@ +export * from "./services"; diff --git a/app/client/src/api/interceptors/index.ts b/app/client/src/api/interceptors/index.ts new file mode 100644 index 000000000000..346dac3b386b --- /dev/null +++ b/app/client/src/api/interceptors/index.ts @@ -0,0 +1,2 @@ +export * from "./request"; +export * from "./response"; diff --git a/app/client/src/api/interceptors/request/addAnonymousUserIdHeader.ts b/app/client/src/api/interceptors/request/addAnonymousUserIdHeader.ts new file mode 100644 index 000000000000..d87e95c63072 --- /dev/null +++ b/app/client/src/api/interceptors/request/addAnonymousUserIdHeader.ts @@ -0,0 +1,16 @@ +import type { InternalAxiosRequestConfig } from "axios"; + +export const addAnonymousUserIdHeader = ( + config: InternalAxiosRequestConfig, + options: { anonymousId?: string; segmentEnabled?: boolean }, +) => { + const { anonymousId, segmentEnabled } = options; + + config.headers = config.headers || {}; + + if (segmentEnabled && anonymousId) { + config.headers["x-anonymous-user-id"] = anonymousId; + } + + return config; +}; diff --git a/app/client/src/api/interceptors/request/addEnvironmentHeader.ts b/app/client/src/api/interceptors/request/addEnvironmentHeader.ts new file mode 100644 index 000000000000..82c9d89fc418 --- /dev/null +++ b/app/client/src/api/interceptors/request/addEnvironmentHeader.ts @@ -0,0 +1,19 @@ +import type { InternalAxiosRequestConfig } from "axios"; +import { ENV_ENABLED_ROUTES_REGEX } from "ee/constants/ApiConstants"; + +export const addEnvironmentHeader = ( + config: InternalAxiosRequestConfig, + options: { env: string }, +) => { + const { env } = options; + + config.headers = config.headers || {}; + + if (ENV_ENABLED_ROUTES_REGEX.test(config.url?.split("?")[0] || "")) { + if (env) { + config.headers["X-Appsmith-EnvironmentId"] = env; + } + } + + return config; +}; diff --git a/app/client/src/api/interceptors/request/addGitBranchHeader.ts b/app/client/src/api/interceptors/request/addGitBranchHeader.ts new file mode 100644 index 000000000000..b15105bcb833 --- /dev/null +++ b/app/client/src/api/interceptors/request/addGitBranchHeader.ts @@ -0,0 +1,16 @@ +import type { InternalAxiosRequestConfig } from "axios"; + +export const addGitBranchHeader = ( + config: InternalAxiosRequestConfig, + options: { branch?: string }, +) => { + const { branch } = options; + + config.headers = config.headers || {}; + + if (branch) { + config.headers.branchName = branch; + } + + return config; +}; diff --git a/app/client/src/api/interceptors/request/addPerformanceMonitoringHeaders.ts b/app/client/src/api/interceptors/request/addPerformanceMonitoringHeaders.ts new file mode 100644 index 000000000000..53ec7ad1bdef --- /dev/null +++ b/app/client/src/api/interceptors/request/addPerformanceMonitoringHeaders.ts @@ -0,0 +1,10 @@ +import type { InternalAxiosRequestConfig } from "axios"; + +export const addPerformanceMonitoringHeaders = ( + config: InternalAxiosRequestConfig, +) => { + config.headers = config.headers || {}; + config.headers["timer"] = performance.now(); + + return config; +}; diff --git a/app/client/src/api/interceptors/request/addRequestedByHeader.ts b/app/client/src/api/interceptors/request/addRequestedByHeader.ts new file mode 100644 index 000000000000..c64a363dc87c --- /dev/null +++ b/app/client/src/api/interceptors/request/addRequestedByHeader.ts @@ -0,0 +1,13 @@ +import type { InternalAxiosRequestConfig } from "axios"; + +export const addRequestedByHeader = (config: InternalAxiosRequestConfig) => { + config.headers = config.headers || {}; + + const methodUpper = config.method?.toUpperCase(); + + if (methodUpper && methodUpper !== "GET" && methodUpper !== "HEAD") { + config.headers["X-Requested-By"] = "Appsmith"; + } + + return config; +}; diff --git a/app/client/src/api/interceptors/request/apiRequestInterceptor.ts b/app/client/src/api/interceptors/request/apiRequestInterceptor.ts new file mode 100644 index 000000000000..8f7919c067dc --- /dev/null +++ b/app/client/src/api/interceptors/request/apiRequestInterceptor.ts @@ -0,0 +1,64 @@ +import store from "store"; +import { compose } from "@appsmith/utils"; +import { getAppsmithConfigs } from "ee/configs"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import { isAirgapped } from "ee/utils/airgapHelpers"; +import type { InternalAxiosRequestConfig } from "axios"; + +import getQueryParamsObject from "utils/getQueryParamsObject"; +import { addRequestedByHeader } from "./addRequestedByHeader"; +import { increaseGitApiTimeout } from "./increaseGitApiTimeout"; +import { getCurrentGitBranch } from "selectors/gitSyncSelectors"; +import { getCurrentEnvironmentId } from "ee/selectors/environmentSelectors"; +import { addGitBranchHeader as _addGitBranchHeader } from "./addGitBranchHeader"; +import { addPerformanceMonitoringHeaders } from "./addPerformanceMonitoringHeaders"; +import { addEnvironmentHeader as _addEnvironmentHeader } from "./addEnvironmentHeader"; +import { blockAirgappedRoutes as _blockAirgappedRoutes } from "./blockAirgappedRoutes"; +import { addAnonymousUserIdHeader as _addAnonymousUserIdHeader } from "./addAnonymousUserIdHeader"; + +/** + * Note: Why can't we use store.getState() or isGapgapped() directly in the interceptor? + * The main reason is to easily test the interceptor. When we use store.getState() or isAirgapped() directly in the interceptor, + * we need to mock the store or isAirgapped() in the test file and it becomes difficult and messy mocking things just to test the interceptor. + */ +const blockAirgappedRoutes = (config: InternalAxiosRequestConfig) => { + const isAirgappedInstance = isAirgapped(); + + return _blockAirgappedRoutes(config, { isAirgapped: isAirgappedInstance }); +}; + +const addGitBranchHeader = (config: InternalAxiosRequestConfig) => { + const state = store.getState(); + const branch = getCurrentGitBranch(state) || getQueryParamsObject().branch; + + return _addGitBranchHeader(config, { branch }); +}; + +const addEnvironmentHeader = (config: InternalAxiosRequestConfig) => { + const state = store.getState(); + const activeEnv = getCurrentEnvironmentId(state); + + return _addEnvironmentHeader(config, { env: activeEnv }); +}; + +const addAnonymousUserIdHeader = (config: InternalAxiosRequestConfig) => { + const appsmithConfig = getAppsmithConfigs(); + const anonymousId = AnalyticsUtil.getAnonymousId(); + const segmentEnabled = appsmithConfig.segment.enabled; + + return _addAnonymousUserIdHeader(config, { anonymousId, segmentEnabled }); +}; + +export const apiRequestInterceptor = (config: InternalAxiosRequestConfig) => { + const interceptorPipeline = compose( + blockAirgappedRoutes, + addRequestedByHeader, + addGitBranchHeader, + increaseGitApiTimeout, + addEnvironmentHeader, + addAnonymousUserIdHeader, + addPerformanceMonitoringHeaders, + ); + + return interceptorPipeline(config); +}; diff --git a/app/client/src/api/interceptors/request/blockAirgappedRoutes.ts b/app/client/src/api/interceptors/request/blockAirgappedRoutes.ts new file mode 100644 index 000000000000..d8d4d54ee805 --- /dev/null +++ b/app/client/src/api/interceptors/request/blockAirgappedRoutes.ts @@ -0,0 +1,29 @@ +import type { InternalAxiosRequestConfig } from "axios"; +import { BLOCKED_ROUTES_REGEX } from "ee/constants/ApiConstants"; + +const blockAirgappedRoutes = ( + request: InternalAxiosRequestConfig, + options: { isAirgapped: boolean }, +) => { + const { url } = request; + const { isAirgapped } = options; + + if (isAirgapped && url && BLOCKED_ROUTES_REGEX.test(url)) { + request.adapter = async (config) => { + return new Promise((resolve) => { + resolve({ + data: null, + status: 200, + statusText: "OK", + headers: {}, + config, + request, + }); + }); + }; + } + + return request; +}; + +export { blockAirgappedRoutes }; diff --git a/app/client/src/api/interceptors/request/increaseGitApiTimeout.ts b/app/client/src/api/interceptors/request/increaseGitApiTimeout.ts new file mode 100644 index 000000000000..4aa20d7611cd --- /dev/null +++ b/app/client/src/api/interceptors/request/increaseGitApiTimeout.ts @@ -0,0 +1,9 @@ +import type { InternalAxiosRequestConfig } from "axios"; + +export const increaseGitApiTimeout = (config: InternalAxiosRequestConfig) => { + if (config.url?.indexOf("/git/") !== -1) { + config.timeout = 1000 * 120; + } + + return config; +}; diff --git a/app/client/src/api/interceptors/request/index.ts b/app/client/src/api/interceptors/request/index.ts new file mode 100644 index 000000000000..6f9957f00ec2 --- /dev/null +++ b/app/client/src/api/interceptors/request/index.ts @@ -0,0 +1,8 @@ +export { addGitBranchHeader } from "./addGitBranchHeader"; +export { blockAirgappedRoutes } from "./blockAirgappedRoutes"; +export { addRequestedByHeader } from "./addRequestedByHeader"; +export { addEnvironmentHeader } from "./addEnvironmentHeader"; +export { apiRequestInterceptor } from "./apiRequestInterceptor"; +export { increaseGitApiTimeout } from "./increaseGitApiTimeout"; +export { addAnonymousUserIdHeader } from "./addAnonymousUserIdHeader"; +export { addPerformanceMonitoringHeaders } from "./addPerformanceMonitoringHeaders"; diff --git a/app/client/src/api/interceptors/response/apiFailureResponseInterceptor.ts b/app/client/src/api/interceptors/response/apiFailureResponseInterceptor.ts new file mode 100644 index 000000000000..f3c4d3c4677c --- /dev/null +++ b/app/client/src/api/interceptors/response/apiFailureResponseInterceptor.ts @@ -0,0 +1,29 @@ +import type { AxiosError } from "axios"; +import type { ApiResponse, ErrorHandler } from "api/types"; + +import * as failureHandlers from "./failureHandlers"; + +export const apiFailureResponseInterceptor = async ( + error: AxiosError, +) => { + const handlers: ErrorHandler[] = [ + failureHandlers.handle413Error, + failureHandlers.handleOfflineError, + failureHandlers.handleCancelError, + failureHandlers.handleExecuteActionError, + failureHandlers.handleTimeoutError, + failureHandlers.handleServerError, + failureHandlers.handleUnauthorizedError, + failureHandlers.handleNotFoundError, + ]; + + for (const handler of handlers) { + const result = await handler(error); + + if (result !== null) { + return result; + } + } + + return Promise.reject(error); +}; diff --git a/app/client/src/api/interceptors/response/apiSuccessResponseInterceptor.ts b/app/client/src/api/interceptors/response/apiSuccessResponseInterceptor.ts new file mode 100644 index 000000000000..ca18a9ea4329 --- /dev/null +++ b/app/client/src/api/interceptors/response/apiSuccessResponseInterceptor.ts @@ -0,0 +1,16 @@ +import { + validateJsonResponseMeta, + addExecutionMetaProperties, +} from "api/helpers"; +import type { AxiosResponse } from "axios"; +import { EXECUTION_ACTION_REGEX } from "ee/constants/ApiConstants"; + +export const apiSuccessResponseInterceptor = (response: AxiosResponse) => { + if (response?.config?.url?.match(EXECUTION_ACTION_REGEX)) { + return addExecutionMetaProperties(response); + } + + validateJsonResponseMeta(response); + + return response.data; +}; diff --git a/app/client/src/api/interceptors/response/failureHandlers/handle413Error.ts b/app/client/src/api/interceptors/response/failureHandlers/handle413Error.ts new file mode 100644 index 000000000000..301b1c2c97a8 --- /dev/null +++ b/app/client/src/api/interceptors/response/failureHandlers/handle413Error.ts @@ -0,0 +1,25 @@ +import type { AxiosError } from "axios"; +import { + createMessage, + ERROR_413, + GENERIC_API_EXECUTION_ERROR, +} from "ee/constants/messages"; + +export const handle413Error = async (error: AxiosError) => { + if (error?.response?.status === 413) { + return Promise.reject({ + ...error, + clientDefinedError: true, + statusCode: "AE-APP-4013", + message: createMessage(ERROR_413, 100), + pluginErrorDetails: { + appsmithErrorCode: "AE-APP-4013", + appsmithErrorMessage: createMessage(ERROR_413, 100), + errorType: "INTERNAL_ERROR", // this value is from the server, hence cannot construct enum type. + title: createMessage(GENERIC_API_EXECUTION_ERROR), + }, + }); + } + + return null; +}; diff --git a/app/client/src/api/interceptors/response/failureHandlers/handleCancelError.ts b/app/client/src/api/interceptors/response/failureHandlers/handleCancelError.ts new file mode 100644 index 000000000000..f579d8bc90b4 --- /dev/null +++ b/app/client/src/api/interceptors/response/failureHandlers/handleCancelError.ts @@ -0,0 +1,11 @@ +import axios from "axios"; +import type { AxiosError } from "axios"; +import { UserCancelledActionExecutionError } from "sagas/ActionExecution/errorUtils"; + +export async function handleCancelError(error: AxiosError) { + if (axios.isCancel(error)) { + throw new UserCancelledActionExecutionError(); + } + + return null; +} diff --git a/app/client/src/api/interceptors/response/failureHandlers/handleExecuteActionError.ts b/app/client/src/api/interceptors/response/failureHandlers/handleExecuteActionError.ts new file mode 100644 index 000000000000..6a1e0c4f277d --- /dev/null +++ b/app/client/src/api/interceptors/response/failureHandlers/handleExecuteActionError.ts @@ -0,0 +1,14 @@ +import type { AxiosError, AxiosResponse } from "axios"; +import { addExecutionMetaProperties } from "api/helpers"; +import { EXECUTION_ACTION_REGEX } from "ee/constants/ApiConstants"; + +export function handleExecuteActionError(error: AxiosError) { + const isExecutionActionURL = + error.config && error?.config?.url?.match(EXECUTION_ACTION_REGEX); + + if (isExecutionActionURL) { + return addExecutionMetaProperties(error?.response as AxiosResponse); + } + + return null; +} diff --git a/app/client/src/api/interceptors/response/failureHandlers/handleMissingResponseMeta.ts b/app/client/src/api/interceptors/response/failureHandlers/handleMissingResponseMeta.ts new file mode 100644 index 000000000000..4153a6f868bc --- /dev/null +++ b/app/client/src/api/interceptors/response/failureHandlers/handleMissingResponseMeta.ts @@ -0,0 +1,17 @@ +import type { AxiosError } from "axios"; +import * as Sentry from "@sentry/react"; +import type { ApiResponse } from "api/types"; + +export const handleMissingResponseMeta = async ( + error: AxiosError, +) => { + if (error.response?.data && !error.response.data.responseMeta) { + Sentry.captureException(new Error("Api responded without response meta"), { + contexts: { response: { ...error.response.data } }, + }); + + return Promise.reject(error.response.data); + } + + return null; +}; diff --git a/app/client/src/api/interceptors/response/failureHandlers/handleNotFoundError.ts b/app/client/src/api/interceptors/response/failureHandlers/handleNotFoundError.ts new file mode 100644 index 000000000000..21e2aa42f313 --- /dev/null +++ b/app/client/src/api/interceptors/response/failureHandlers/handleNotFoundError.ts @@ -0,0 +1,34 @@ +import { + API_STATUS_CODES, + ERROR_CODES, + SERVER_ERROR_CODES, +} from "ee/constants/ApiConstants"; +import * as Sentry from "@sentry/react"; +import type { AxiosError } from "axios"; +import type { ApiResponse } from "api/types"; +import { is404orAuthPath } from "api/helpers"; + +export async function handleNotFoundError(error: AxiosError) { + if (is404orAuthPath()) return null; + + const errorData = + error?.response?.data.responseMeta ?? ({} as ApiResponse["responseMeta"]); + + if ( + errorData.status === API_STATUS_CODES.RESOURCE_NOT_FOUND && + errorData.error?.code && + (SERVER_ERROR_CODES.RESOURCE_NOT_FOUND.includes(errorData.error?.code) || + SERVER_ERROR_CODES.UNABLE_TO_FIND_PAGE.includes(errorData?.error?.code)) + ) { + Sentry.captureException(error); + + return Promise.reject({ + ...error, + code: ERROR_CODES.PAGE_NOT_FOUND, + message: "Resource Not Found", + show: false, + }); + } + + return null; +} diff --git a/app/client/src/api/interceptors/response/failureHandlers/handleOfflineError.ts b/app/client/src/api/interceptors/response/failureHandlers/handleOfflineError.ts new file mode 100644 index 000000000000..2fa9fa16492f --- /dev/null +++ b/app/client/src/api/interceptors/response/failureHandlers/handleOfflineError.ts @@ -0,0 +1,13 @@ +import type { AxiosError } from "axios"; +import { createMessage, ERROR_0 } from "ee/constants/messages"; + +export const handleOfflineError = async (error: AxiosError) => { + if (!window.navigator.onLine) { + return Promise.reject({ + ...error, + message: createMessage(ERROR_0), + }); + } + + return null; +}; diff --git a/app/client/src/api/interceptors/response/failureHandlers/handleServerError.ts b/app/client/src/api/interceptors/response/failureHandlers/handleServerError.ts new file mode 100644 index 000000000000..2092d72b0079 --- /dev/null +++ b/app/client/src/api/interceptors/response/failureHandlers/handleServerError.ts @@ -0,0 +1,15 @@ +import type { AxiosError } from "axios"; +import { createMessage, ERROR_500 } from "ee/constants/messages"; +import { API_STATUS_CODES, ERROR_CODES } from "ee/constants/ApiConstants"; + +export const handleServerError = async (error: AxiosError) => { + if (error.response?.status === API_STATUS_CODES.SERVER_ERROR) { + return Promise.reject({ + ...error, + code: ERROR_CODES.SERVER_ERROR, + message: createMessage(ERROR_500), + }); + } + + return null; +}; diff --git a/app/client/src/api/interceptors/response/failureHandlers/handleTimeoutError.ts b/app/client/src/api/interceptors/response/failureHandlers/handleTimeoutError.ts new file mode 100644 index 000000000000..ffff3eafe768 --- /dev/null +++ b/app/client/src/api/interceptors/response/failureHandlers/handleTimeoutError.ts @@ -0,0 +1,22 @@ +import type { AxiosError } from "axios"; +import { + ERROR_CODES, + TIMEOUT_ERROR_REGEX, + AXIOS_CONNECTION_ABORTED_CODE, +} from "ee/constants/ApiConstants"; +import { createMessage, SERVER_API_TIMEOUT_ERROR } from "ee/constants/messages"; + +export const handleTimeoutError = async (error: AxiosError) => { + if ( + error.code === AXIOS_CONNECTION_ABORTED_CODE && + error.message?.match(TIMEOUT_ERROR_REGEX) + ) { + return Promise.reject({ + ...error, + message: createMessage(SERVER_API_TIMEOUT_ERROR), + code: ERROR_CODES.REQUEST_TIMEOUT, + }); + } + + return null; +}; diff --git a/app/client/src/api/interceptors/response/failureHandlers/handleUnauthorizedError.ts b/app/client/src/api/interceptors/response/failureHandlers/handleUnauthorizedError.ts new file mode 100644 index 000000000000..343ec7022e48 --- /dev/null +++ b/app/client/src/api/interceptors/response/failureHandlers/handleUnauthorizedError.ts @@ -0,0 +1,34 @@ +import store from "store"; +import type { AxiosError } from "axios"; +import * as Sentry from "@sentry/react"; +import { is404orAuthPath } from "api/helpers"; +import { logoutUser } from "actions/userActions"; +import { AUTH_LOGIN_URL } from "constants/routes"; +import { API_STATUS_CODES, ERROR_CODES } from "ee/constants/ApiConstants"; + +export const handleUnauthorizedError = async (error: AxiosError) => { + if (is404orAuthPath()) return null; + + if (error.response?.status === API_STATUS_CODES.REQUEST_NOT_AUTHORISED) { + const currentUrl = `${window.location.href}`; + + store.dispatch( + logoutUser({ + redirectURL: `${AUTH_LOGIN_URL}?redirectUrl=${encodeURIComponent( + currentUrl, + )}`, + }), + ); + + Sentry.captureException(error); + + return Promise.reject({ + ...error, + code: ERROR_CODES.REQUEST_NOT_AUTHORISED, + message: "Unauthorized. Redirecting to login page...", + show: false, + }); + } + + return null; +}; diff --git a/app/client/src/api/interceptors/response/failureHandlers/index.ts b/app/client/src/api/interceptors/response/failureHandlers/index.ts new file mode 100644 index 000000000000..2352095f1deb --- /dev/null +++ b/app/client/src/api/interceptors/response/failureHandlers/index.ts @@ -0,0 +1,9 @@ +export { handle413Error } from "./handle413Error"; +export { handleServerError } from "./handleServerError"; +export { handleCancelError } from "./handleCancelError"; +export { handleOfflineError } from "./handleOfflineError"; +export { handleTimeoutError } from "./handleTimeoutError"; +export { handleNotFoundError } from "./handleNotFoundError"; +export { handleUnauthorizedError } from "./handleUnauthorizedError"; +export { handleExecuteActionError } from "./handleExecuteActionError"; +export { handleMissingResponseMeta } from "./handleMissingResponseMeta"; diff --git a/app/client/src/api/interceptors/response/index.ts b/app/client/src/api/interceptors/response/index.ts new file mode 100644 index 000000000000..5177d545922d --- /dev/null +++ b/app/client/src/api/interceptors/response/index.ts @@ -0,0 +1,2 @@ +export { apiFailureResponseInterceptor } from "./apiFailureResponseInterceptor"; +export { apiSuccessResponseInterceptor } from "./apiSuccessResponseInterceptor"; diff --git a/app/client/src/api/services/AppThemingApi/api.ts b/app/client/src/api/services/AppThemingApi/api.ts new file mode 100644 index 000000000000..e7d22dfed85c --- /dev/null +++ b/app/client/src/api/services/AppThemingApi/api.ts @@ -0,0 +1,38 @@ +import { api } from "api/core"; +import type { AppTheme } from "entities/AppTheming"; + +const baseURL = "/v1"; + +export async function fetchThemes(applicationId: string) { + const url = `${baseURL}/themes/applications/${applicationId}`; + + return api.get(url); +} + +export async function fetchSelected(applicationId: string, mode = "EDIT") { + const url = `${baseURL}/themes/applications/${applicationId}/current`; + + return api.get(url, { params: { mode } }); +} + +export async function updateTheme(applicationId: string, theme: AppTheme) { + const url = `${baseURL}/themes/applications/${applicationId}`; + const payload = { + ...theme, + new: undefined, + }; + + return api.put(url, payload); +} + +export async function changeTheme(applicationId: string, theme: AppTheme) { + const url = `${baseURL}/applications/${applicationId}/themes/${theme.id}`; + + return api.patch(url, theme); +} + +export async function deleteTheme(themeId: string) { + const url = `${baseURL}/themes/${themeId}`; + + return api.delete(url); +} diff --git a/app/client/src/api/services/AppThemingApi/index.ts b/app/client/src/api/services/AppThemingApi/index.ts new file mode 100644 index 000000000000..d158c5764011 --- /dev/null +++ b/app/client/src/api/services/AppThemingApi/index.ts @@ -0,0 +1 @@ +export * from "./api"; diff --git a/app/client/src/api/services/ConsolidatedPageLoadApi/api.ts b/app/client/src/api/services/ConsolidatedPageLoadApi/api.ts new file mode 100644 index 000000000000..4772853c901e --- /dev/null +++ b/app/client/src/api/services/ConsolidatedPageLoadApi/api.ts @@ -0,0 +1,20 @@ +import { api } from "api/core"; +import type { InitConsolidatedApi } from "sagas/InitSagas"; + +const BASE_URL = "v1/consolidated-api"; +const VIEW_URL = `${BASE_URL}/view`; +const EDIT_URL = `${BASE_URL}/edit`; + +export const getConsolidatedPageLoadDataView = async (params: { + applicationId?: string; + defaultPageId?: string; +}) => { + return api.get(VIEW_URL, { params }); +}; + +export const getConsolidatedPageLoadDataEdit = async (params: { + applicationId?: string; + defaultPageId?: string; +}) => { + return api.get(EDIT_URL, { params }); +}; diff --git a/app/client/src/api/services/ConsolidatedPageLoadApi/index.ts b/app/client/src/api/services/ConsolidatedPageLoadApi/index.ts new file mode 100644 index 000000000000..d158c5764011 --- /dev/null +++ b/app/client/src/api/services/ConsolidatedPageLoadApi/index.ts @@ -0,0 +1 @@ +export * from "./api"; diff --git a/app/client/src/api/services/index.ts b/app/client/src/api/services/index.ts new file mode 100644 index 000000000000..b1e3b877481d --- /dev/null +++ b/app/client/src/api/services/index.ts @@ -0,0 +1,2 @@ +export * as AppThemingApi from "./AppThemingApi"; +export * as ConsolidatedPageLoadApi from "./ConsolidatedPageLoadApi"; diff --git a/app/client/src/api/types.ts b/app/client/src/api/types.ts new file mode 100644 index 000000000000..4ce60997efff --- /dev/null +++ b/app/client/src/api/types.ts @@ -0,0 +1,24 @@ +import type { AxiosError, AxiosResponse } from "axios"; + +export interface ApiResponseError { + code: string; + message: string; +} + +export interface ApiResponseMeta { + status: number; + success: boolean; + error?: ApiResponseError; +} + +export interface ApiResponse { + responseMeta: ApiResponseMeta; + data: T; + code?: string; +} + +export type AxiosResponseData = AxiosResponse>["data"]; + +export type ErrorHandler = ( + error: AxiosError, +) => Promise; diff --git a/app/client/src/ce/api/ApiUtils.ts b/app/client/src/ce/api/ApiUtils.ts index b157db36686e..9db9f45e1d7e 100644 --- a/app/client/src/ce/api/ApiUtils.ts +++ b/app/client/src/ce/api/ApiUtils.ts @@ -1,289 +1,7 @@ -import { - createMessage, - ERROR_0, - ERROR_413, - ERROR_500, - GENERIC_API_EXECUTION_ERROR, - SERVER_API_TIMEOUT_ERROR, -} from "ee/constants/messages"; -import type { AxiosRequestConfig, AxiosResponse } from "axios"; -import axios from "axios"; -import { - API_STATUS_CODES, - ERROR_CODES, - SERVER_ERROR_CODES, -} from "ee/constants/ApiConstants"; -import log from "loglevel"; -import type { ActionExecutionResponse } from "api/ActionAPI"; -import store from "store"; -import { logoutUser } from "actions/userActions"; -import { AUTH_LOGIN_URL } from "constants/routes"; -import { getCurrentGitBranch } from "selectors/gitSyncSelectors"; -import getQueryParamsObject from "utils/getQueryParamsObject"; -import { UserCancelledActionExecutionError } from "sagas/ActionExecution/errorUtils"; -import AnalyticsUtil from "ee/utils/AnalyticsUtil"; -import { getAppsmithConfigs } from "ee/configs"; -import * as Sentry from "@sentry/react"; -import { CONTENT_TYPE_HEADER_KEY } from "constants/ApiEditorConstants/CommonApiConstants"; -import { isAirgapped } from "ee/utils/airgapHelpers"; -import { getCurrentEnvironmentId } from "ee/selectors/environmentSelectors"; import { UNUSED_ENV_ID } from "constants/EnvironmentContants"; -import { ID_EXTRACTION_REGEX } from "ee/constants/routes/appRoutes"; - -const executeActionRegex = /actions\/execute/; -const timeoutErrorRegex = /timeout of (\d+)ms exceeded/; - -export const axiosConnectionAbortedCode = "ECONNABORTED"; -const appsmithConfig = getAppsmithConfigs(); export const DEFAULT_ENV_ID = UNUSED_ENV_ID; -export const BLOCKED_ROUTES = [ - "v1/app-templates", - "v1/datasources/mocks", - "v1/usage-pulse", - "v1/applications/releaseItems", - "v1/saas", -]; - -export const BLOCKED_ROUTES_REGEX = new RegExp( - `^(${BLOCKED_ROUTES.join("|")})($|/)`, -); - -export const ENV_ENABLED_ROUTES = [ - `v1/datasources/${ID_EXTRACTION_REGEX}/structure`, - `/v1/datasources/${ID_EXTRACTION_REGEX}/trigger`, - "v1/actions/execute", - "v1/saas", -]; - -export const ENV_ENABLED_ROUTES_REGEX = new RegExp( - `^(${ENV_ENABLED_ROUTES.join("|")})($|/)`, -); - -// TODO: Fix this the next time the file is edited -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const makeExecuteActionResponse = (response: any): ActionExecutionResponse => ({ - ...response.data, - clientMeta: { - size: response.headers["content-length"], - duration: Number(performance.now() - response.config.timer).toFixed(), - }, -}); - -const is404orAuthPath = () => { - const pathName = window.location.pathname; - - return /^\/404/.test(pathName) || /^\/user\/\w+/.test(pathName); -}; - -export const blockedApiRoutesForAirgapInterceptor = async ( - config: AxiosRequestConfig, -) => { - const { url } = config; - - const isAirgappedInstance = isAirgapped(); - - if (isAirgappedInstance && url && BLOCKED_ROUTES_REGEX.test(url)) { - return Promise.resolve({ data: null, status: 200 }); - } - - return config; -}; - -// Request interceptor will add a timer property to the request. -// this will be used to calculate the time taken for an action -// execution request -export const apiRequestInterceptor = (config: AxiosRequestConfig) => { - config.headers = config.headers ?? {}; - - // Add header for CSRF protection. - const methodUpper = config.method?.toUpperCase(); - - if (methodUpper && methodUpper !== "GET" && methodUpper !== "HEAD") { - config.headers["X-Requested-By"] = "Appsmith"; - } - - const state = store.getState(); - const branch = getCurrentGitBranch(state) || getQueryParamsObject().branch; - - if (branch && config.headers) { - config.headers.branchName = branch; - } - - if (config.url?.indexOf("/git/") !== -1) { - config.timeout = 1000 * 120; // increase timeout for git specific APIs - } - - if (ENV_ENABLED_ROUTES_REGEX.test(config.url?.split("?")[0] || "")) { - // Add header for environment name - const activeEnv = getCurrentEnvironmentId(state); - - if (activeEnv && config.headers) { - config.headers["X-Appsmith-EnvironmentId"] = activeEnv; - } - } - - const anonymousId = AnalyticsUtil.getAnonymousId(); - - appsmithConfig.segment.enabled && - anonymousId && - (config.headers["x-anonymous-user-id"] = anonymousId); - - return { ...config, timer: performance.now() }; -}; - -// On success of an API, if the api is an action execution, -// add the client meta object with size and time taken info -// otherwise just return the data -export const apiSuccessResponseInterceptor = ( - response: AxiosResponse, -): AxiosResponse["data"] => { - if (response.config.url) { - if (response.config.url.match(executeActionRegex)) { - return makeExecuteActionResponse(response); - } - } - - if ( - response.headers[CONTENT_TYPE_HEADER_KEY] === "application/json" && - !response.data.responseMeta - ) { - Sentry.captureException(new Error("Api responded without response meta"), { - contexts: { response: response.data }, - }); - } - - return response.data; -}; - -// Handle different api failure scenarios -// TODO: Fix this the next time the file is edited -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const apiFailureResponseInterceptor = async (error: any) => { - // this can be extended to other errors we want to catch. - // in this case it is 413. - if (error && error?.response && error?.response.status === 413) { - return Promise.reject({ - ...error, - clientDefinedError: true, - statusCode: "AE-APP-4013", - message: createMessage(ERROR_413, 100), - pluginErrorDetails: { - appsmithErrorCode: "AE-APP-4013", - appsmithErrorMessage: createMessage(ERROR_413, 100), - errorType: "INTERNAL_ERROR", // this value is from the server, hence cannot construct enum type. - title: createMessage(GENERIC_API_EXECUTION_ERROR), - }, - }); - } - - // Return error when there is no internet - if (!window.navigator.onLine) { - return Promise.reject({ - ...error, - message: createMessage(ERROR_0), - }); - } - - // Return if the call was cancelled via cancel token - if (axios.isCancel(error)) { - throw new UserCancelledActionExecutionError(); - } - - // Return modified response if action execution failed - if (error.config && error.config.url.match(executeActionRegex)) { - return makeExecuteActionResponse(error.response); - } - - // Return error if any timeout happened in other api calls - if ( - error.code === axiosConnectionAbortedCode && - error.message && - error.message.match(timeoutErrorRegex) - ) { - return Promise.reject({ - ...error, - message: createMessage(SERVER_API_TIMEOUT_ERROR), - code: ERROR_CODES.REQUEST_TIMEOUT, - }); - } - - if (error.response) { - if (error.response.status === API_STATUS_CODES.SERVER_ERROR) { - return Promise.reject({ - ...error, - code: ERROR_CODES.SERVER_ERROR, - message: createMessage(ERROR_500), - }); - } - - // The request was made and the server responded with a status code - // that falls out of the range of 2xx - if (!is404orAuthPath()) { - const currentUrl = `${window.location.href}`; - - if (error.response.status === API_STATUS_CODES.REQUEST_NOT_AUTHORISED) { - // Redirect to login and set a redirect url. - store.dispatch( - logoutUser({ - redirectURL: `${AUTH_LOGIN_URL}?redirectUrl=${encodeURIComponent( - currentUrl, - )}`, - }), - ); - Sentry.captureException(error); - - return Promise.reject({ - ...error, - code: ERROR_CODES.REQUEST_NOT_AUTHORISED, - message: "Unauthorized. Redirecting to login page...", - show: false, - }); - } - - const errorData = error.response.data.responseMeta ?? {}; - - if ( - errorData.status === API_STATUS_CODES.RESOURCE_NOT_FOUND && - (SERVER_ERROR_CODES.RESOURCE_NOT_FOUND.includes(errorData.error.code) || - SERVER_ERROR_CODES.UNABLE_TO_FIND_PAGE.includes(errorData.error.code)) - ) { - Sentry.captureException(error); - - return Promise.reject({ - ...error, - code: ERROR_CODES.PAGE_NOT_FOUND, - message: "Resource Not Found", - show: false, - }); - } - } - - if (error.response.data.responseMeta) { - return Promise.resolve(error.response.data); - } - - Sentry.captureException(new Error("Api responded without response meta"), { - contexts: { response: error.response.data }, - }); - - return Promise.reject(error.response.data); - } else if (error.request) { - // The request was made but no response was received - // `error.request` is an instance of XMLHttpRequest in the browser and an instance of - // http.ClientRequest in node.js - log.error(error.request); - } else { - // Something happened in setting up the request that triggered an Error - log.error("Error", error.message); - } - - log.debug(error.config); - - return Promise.resolve(error); -}; - // function to get the default environment export const getDefaultEnvId = () => { return DEFAULT_ENV_ID; diff --git a/app/client/src/ce/constants/ApiConstants.tsx b/app/client/src/ce/constants/ApiConstants.tsx index 75ad958e7889..35ca4caea3b3 100644 --- a/app/client/src/ce/constants/ApiConstants.tsx +++ b/app/client/src/ce/constants/ApiConstants.tsx @@ -1,3 +1,8 @@ +import type { CreateAxiosDefaults } from "axios"; +import { ID_EXTRACTION_REGEX } from "constants/routes"; +import { convertObjectToQueryParams } from "utils/URLUtils"; +import { UNUSED_ENV_ID } from "constants/EnvironmentContants"; + export const REQUEST_TIMEOUT_MS = 20000; export const DEFAULT_ACTION_TIMEOUT = 10000; export const DEFAULT_EXECUTE_ACTION_TIMEOUT_MS = 15000; @@ -5,6 +10,45 @@ export const DEFAULT_TEST_DATA_SOURCE_TIMEOUT_MS = 30000; export const DEFAULT_APPSMITH_AI_QUERY_TIMEOUT_MS = 60000; export const FILE_UPLOAD_TRIGGER_TIMEOUT_MS = 60000; +export const DEFAULT_AXIOS_CONFIG: CreateAxiosDefaults = { + baseURL: "/api/", + timeout: REQUEST_TIMEOUT_MS, + headers: { + "Content-Type": "application/json", + }, + withCredentials: true, + paramsSerializer: convertObjectToQueryParams, +}; + +export const EXECUTION_ACTION_REGEX = /actions\/execute/; +export const TIMEOUT_ERROR_REGEX = /timeout of (\d+)ms exceeded/; +export const AXIOS_CONNECTION_ABORTED_CODE = "ECONNABORTED"; + +export const DEFAULT_ENV_ID = UNUSED_ENV_ID; + +export const BLOCKED_ROUTES = [ + "v1/app-templates", + "v1/datasources/mocks", + "v1/usage-pulse", + "v1/applications/releaseItems", + "v1/saas", +]; + +export const BLOCKED_ROUTES_REGEX = new RegExp( + `^(${BLOCKED_ROUTES.join("|")})($|/)`, +); + +export const ENV_ENABLED_ROUTES = [ + `v1/datasources/${ID_EXTRACTION_REGEX}/structure`, + `/v1/datasources/${ID_EXTRACTION_REGEX}/trigger`, + "v1/actions/execute", + "v1/saas", +]; + +export const ENV_ENABLED_ROUTES_REGEX = new RegExp( + `^(${ENV_ENABLED_ROUTES.join("|")})($|/)`, +); + export enum API_STATUS_CODES { REQUEST_NOT_AUTHORISED = 401, RESOURCE_NOT_FOUND = 404, diff --git a/app/client/src/ce/sagas/PageSagas.tsx b/app/client/src/ce/sagas/PageSagas.tsx index 2ab7c28753ea..12e52ba930c5 100644 --- a/app/client/src/ce/sagas/PageSagas.tsx +++ b/app/client/src/ce/sagas/PageSagas.tsx @@ -149,7 +149,7 @@ import type { LayoutSystemTypes } from "layoutSystems/types"; import { getIsAnvilLayout } from "layoutSystems/anvil/integrations/selectors"; import { convertToBasePageIdSelector } from "selectors/pageListSelectors"; import type { Page } from "entities/Page"; -import ConsolidatedPageLoadApi from "api/ConsolidatedPageLoadApi"; +import { ConsolidatedPageLoadApi } from "api"; export const checkIfMigrationIsNeeded = ( fetchPageResponse?: FetchPageResponse, diff --git a/app/client/src/sagas/AppThemingSaga.tsx b/app/client/src/sagas/AppThemingSaga.tsx index 5a4de787d424..3f57b894e4bc 100644 --- a/app/client/src/sagas/AppThemingSaga.tsx +++ b/app/client/src/sagas/AppThemingSaga.tsx @@ -11,8 +11,15 @@ import { ReduxActionErrorTypes, ReduxActionTypes, } from "ee/constants/ReduxActionConstants"; -import ThemingApi from "api/AppThemingApi"; -import { all, takeLatest, put, select, call } from "redux-saga/effects"; +import { AppThemingApi } from "api"; +import { + all, + takeLatest, + put, + select, + call, + type SagaReturnType, +} from "redux-saga/effects"; import { toast } from "@appsmith/ads"; import { CHANGE_APP_THEME, @@ -31,8 +38,6 @@ import { getBetaFlag, setBetaFlag, STORAGE_KEYS } from "utils/storage"; import type { UpdateWidgetPropertyPayload } from "actions/controlActions"; import { batchUpdateMultipleWidgetProperties } from "actions/controlActions"; import { getPropertiesToUpdateForReset } from "entities/AppTheming/utils"; -import type { ApiResponse } from "api/ApiResponses"; -import type { AppTheme } from "entities/AppTheming"; import type { CanvasWidgetsReduxState } from "reducers/entityReducers/canvasWidgetsReducer"; import { getCurrentApplicationId, @@ -75,11 +80,10 @@ export function* fetchAppThemes(action: ReduxAction) { try { const { applicationId, themes } = action.payload; - const response: ApiResponse = yield call( - getFromServerWhenNoPrefetchedResult, - themes, - async () => ThemingApi.fetchThemes(applicationId), - ); + const response: SagaReturnType = + yield call(getFromServerWhenNoPrefetchedResult, themes, async () => + AppThemingApi.fetchThemes(applicationId), + ); yield put({ type: ReduxActionTypes.FETCH_APP_THEMES_SUCCESS, @@ -112,11 +116,10 @@ export function* fetchAppSelectedTheme( const applicationVersion = yield select(selectApplicationVersion); try { - const response: ApiResponse = yield call( - getFromServerWhenNoPrefetchedResult, - currentTheme, - async () => ThemingApi.fetchSelected(applicationId, mode), - ); + const response: SagaReturnType = + yield call(getFromServerWhenNoPrefetchedResult, currentTheme, async () => + AppThemingApi.fetchSelected(applicationId, mode), + ); if (response?.data) { yield put({ @@ -161,7 +164,7 @@ export function* updateSelectedTheme( const canvasWidgets: CanvasWidgetsReduxState = yield select(getCanvasWidgets); try { - yield ThemingApi.updateTheme(applicationId, theme); + yield AppThemingApi.updateTheme(applicationId, theme); yield put({ type: ReduxActionTypes.UPDATE_SELECTED_APP_THEME_SUCCESS, @@ -197,7 +200,7 @@ export function* changeSelectedTheme( const canvasWidgets: CanvasWidgetsReduxState = yield select(getCanvasWidgets); try { - yield ThemingApi.changeTheme(applicationId, theme); + yield AppThemingApi.changeTheme(applicationId, theme); yield put({ type: ReduxActionTypes.CHANGE_SELECTED_APP_THEME_SUCCESS, @@ -235,7 +238,7 @@ export function* deleteTheme(action: ReduxAction) { const { name, themeId } = action.payload; try { - yield ThemingApi.deleteTheme(themeId); + yield AppThemingApi.deleteTheme(themeId); yield put({ type: ReduxActionTypes.DELETE_APP_THEME_SUCCESS, @@ -289,15 +292,15 @@ function* setDefaultSelectedThemeOnError() { try { // Fetch all system themes - const response: ApiResponse = - yield ThemingApi.fetchThemes(applicationId); + const response: SagaReturnType = + yield AppThemingApi.fetchThemes(applicationId); // Gets default theme const theme = find(response.data, { name: "Default" }); if (theme) { // Update API call to set current theme to default - yield ThemingApi.changeTheme(applicationId, theme); + yield AppThemingApi.changeTheme(applicationId, theme); yield put({ type: ReduxActionTypes.FETCH_SELECTED_APP_THEME_SUCCESS, payload: theme, diff --git a/app/client/src/sagas/InitSagas.ts b/app/client/src/sagas/InitSagas.ts index fc6a0343c30d..497222db194d 100644 --- a/app/client/src/sagas/InitSagas.ts +++ b/app/client/src/sagas/InitSagas.ts @@ -81,8 +81,8 @@ import type { FetchPageResponse, FetchPageResponseData } from "api/PageApi"; import type { AppTheme } from "entities/AppTheming"; import type { Datasource } from "entities/Datasource"; import type { Plugin, PluginFormPayload } from "api/PluginApi"; -import ConsolidatedPageLoadApi from "api/ConsolidatedPageLoadApi"; -import { axiosConnectionAbortedCode } from "ee/api/ApiUtils"; +import { ConsolidatedPageLoadApi } from "api"; +import { AXIOS_CONNECTION_ABORTED_CODE } from "ee/constants/ApiConstants"; import { endSpan, startNestedSpan, @@ -254,7 +254,7 @@ export function* getInitResponses({ if (!isValidResponse) { // its only invalid when there is a axios related error - throw new Error("Error occured " + axiosConnectionAbortedCode); + throw new Error("Error occured " + AXIOS_CONNECTION_ABORTED_CODE); } // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any From 531b6b0865b8cba9f408d6929fc07895f4d6f62f Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Thu, 19 Sep 2024 12:56:29 +0530 Subject: [PATCH 2/8] fix imports --- app/client/src/sagas/ErrorSagas.tsx | 4 ++-- app/client/src/sagas/helper.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/client/src/sagas/ErrorSagas.tsx b/app/client/src/sagas/ErrorSagas.tsx index 3e3e06854c84..1669fb5c4cad 100644 --- a/app/client/src/sagas/ErrorSagas.tsx +++ b/app/client/src/sagas/ErrorSagas.tsx @@ -29,7 +29,7 @@ import { import store from "store"; import * as Sentry from "@sentry/react"; -import { axiosConnectionAbortedCode } from "ee/api/ApiUtils"; +import { AXIOS_CONNECTION_ABORTED_CODE } from "ee/constants/ApiConstants"; import { getLoginUrl } from "ee/utils/adminSettingsHelpers"; import type { PluginErrorDetails } from "api/ActionAPI"; import showToast from "sagas/ToastSagas"; @@ -104,7 +104,7 @@ export function* validateResponse( } // letting `apiFailureResponseInterceptor` handle it this case - if (response?.code === axiosConnectionAbortedCode) { + if (response?.code === AXIOS_CONNECTION_ABORTED_CODE) { return false; } diff --git a/app/client/src/sagas/helper.ts b/app/client/src/sagas/helper.ts index 38ad773c4cbe..18568af0c563 100644 --- a/app/client/src/sagas/helper.ts +++ b/app/client/src/sagas/helper.ts @@ -20,7 +20,7 @@ import set from "lodash/set"; import log from "loglevel"; import { isPlainObject, isString } from "lodash"; import { DATA_BIND_REGEX_GLOBAL } from "constants/BindingsConstants"; -import { apiFailureResponseInterceptor } from "ee/api/ApiUtils"; +import { apiFailureResponseInterceptor } from "api/interceptors"; import { klonaLiteWithTelemetry } from "utils/helpers"; // function to extract all objects that have dynamic values From 39478d7c747a855f0ca3d019c13e04e193ec4a40 Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Thu, 19 Sep 2024 13:13:40 +0530 Subject: [PATCH 3/8] remove apiutils test file --- app/client/src/ce/api/ApiUtils.test.ts | 127 ------------------------- app/client/src/sagas/helper.ts | 4 +- 2 files changed, 3 insertions(+), 128 deletions(-) delete mode 100644 app/client/src/ce/api/ApiUtils.test.ts diff --git a/app/client/src/ce/api/ApiUtils.test.ts b/app/client/src/ce/api/ApiUtils.test.ts deleted file mode 100644 index c1848f77605c..000000000000 --- a/app/client/src/ce/api/ApiUtils.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - apiRequestInterceptor, - apiSuccessResponseInterceptor, - apiFailureResponseInterceptor, - axiosConnectionAbortedCode, -} from "./ApiUtils"; -import type { AxiosRequestConfig, AxiosResponse } from "axios"; -import type { ActionExecutionResponse } from "api/ActionAPI"; -import { - createMessage, - ERROR_0, - SERVER_API_TIMEOUT_ERROR, -} from "ee/constants/messages"; -import { ERROR_CODES } from "ee/constants/ApiConstants"; -import * as Sentry from "@sentry/react"; - -describe("axios api interceptors", () => { - describe("Axios api request interceptor", () => { - it("adds timer to the request object", () => { - const request: AxiosRequestConfig = { - url: "https://app.appsmith.com/v1/api/actions/execute", - }; - const interceptedRequest = apiRequestInterceptor(request); - - expect(interceptedRequest).toHaveProperty("timer"); - }); - }); - - describe("Axios api response success interceptor", () => { - it("transforms an action execution response", () => { - const response: AxiosResponse = { - data: "Test data", - headers: { - "content-length": 123, - "content-type": "application/json", - }, - config: { - url: "https://app.appsmith.com/v1/api/actions/execute", - // @ts-expect-error: type mismatch - timer: 0, - }, - }; - - const interceptedResponse: ActionExecutionResponse = - apiSuccessResponseInterceptor(response); - - expect(interceptedResponse).toHaveProperty("clientMeta"); - expect(interceptedResponse.clientMeta).toHaveProperty("size"); - expect(interceptedResponse.clientMeta.size).toBe(123); - expect(interceptedResponse.clientMeta).toHaveProperty("duration"); - }); - - it("just returns the response data for other requests", () => { - const response: AxiosResponse = { - data: "Test data", - headers: { - "content-type": "application/json", - }, - config: { - url: "https://app.appsmith.com/v1/api/actions", - //@ts-expect-error: type mismatch - timer: 0, - }, - }; - - const interceptedResponse: ActionExecutionResponse = - apiSuccessResponseInterceptor(response); - - expect(interceptedResponse).toBe("Test data"); - }); - }); - - describe("Api response failure interceptor", () => { - beforeEach(() => { - jest.restoreAllMocks(); - }); - - it("checks for no internet errors", () => { - jest.spyOn(navigator, "onLine", "get").mockReturnValue(false); - const interceptedResponse = apiFailureResponseInterceptor({}); - - expect(interceptedResponse).rejects.toStrictEqual({ - message: createMessage(ERROR_0), - }); - }); - - it.todo("handles axios cancel gracefully"); - - it("handles timeout errors", () => { - const error = { - code: axiosConnectionAbortedCode, - message: "timeout of 10000ms exceeded", - }; - const interceptedResponse = apiFailureResponseInterceptor(error); - - expect(interceptedResponse).rejects.toStrictEqual({ - message: createMessage(SERVER_API_TIMEOUT_ERROR), - code: ERROR_CODES.REQUEST_TIMEOUT, - }); - }); - - it("checks for response meta", () => { - const sentrySpy = jest.spyOn(Sentry, "captureException"); - const response: AxiosResponse = { - data: "Test data", - headers: { - "content-type": "application/json", - }, - config: { - url: "https://app.appsmith.com/v1/api/user", - //@ts-expect-error: type mismatch - timer: 0, - }, - }; - - apiSuccessResponseInterceptor(response); - expect(sentrySpy).toHaveBeenCalled(); - - const interceptedFailureResponse = apiFailureResponseInterceptor({ - response, - }); - - expect(interceptedFailureResponse).rejects.toStrictEqual("Test data"); - expect(sentrySpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/app/client/src/sagas/helper.ts b/app/client/src/sagas/helper.ts index 18568af0c563..c720022a3f5a 100644 --- a/app/client/src/sagas/helper.ts +++ b/app/client/src/sagas/helper.ts @@ -237,7 +237,9 @@ export function* getFromServerWhenNoPrefetchedResult( }, status, }, - }); + // TODO: Fix this the next time the file is edited + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); return resp; } From 97af61dc61cb97f058e79f1ba83981f81c728dc7 Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Thu, 19 Sep 2024 15:44:54 +0530 Subject: [PATCH 4/8] use default serializer of axios --- app/client/src/ce/constants/ApiConstants.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/client/src/ce/constants/ApiConstants.tsx b/app/client/src/ce/constants/ApiConstants.tsx index 35ca4caea3b3..b1c7a6c7ee26 100644 --- a/app/client/src/ce/constants/ApiConstants.tsx +++ b/app/client/src/ce/constants/ApiConstants.tsx @@ -1,6 +1,5 @@ import type { CreateAxiosDefaults } from "axios"; import { ID_EXTRACTION_REGEX } from "constants/routes"; -import { convertObjectToQueryParams } from "utils/URLUtils"; import { UNUSED_ENV_ID } from "constants/EnvironmentContants"; export const REQUEST_TIMEOUT_MS = 20000; @@ -17,7 +16,6 @@ export const DEFAULT_AXIOS_CONFIG: CreateAxiosDefaults = { "Content-Type": "application/json", }, withCredentials: true, - paramsSerializer: convertObjectToQueryParams, }; export const EXECUTION_ACTION_REGEX = /actions\/execute/; From 2a0c4763d74504de3f98094ead575ced6d65117f Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Fri, 20 Sep 2024 12:25:10 +0530 Subject: [PATCH 5/8] add missing logic --- .../interceptors/response/apiFailureResponseInterceptor.ts | 6 +++++- app/client/src/sagas/DatasourcesSagas.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/client/src/api/interceptors/response/apiFailureResponseInterceptor.ts b/app/client/src/api/interceptors/response/apiFailureResponseInterceptor.ts index f3c4d3c4677c..eae6f6162126 100644 --- a/app/client/src/api/interceptors/response/apiFailureResponseInterceptor.ts +++ b/app/client/src/api/interceptors/response/apiFailureResponseInterceptor.ts @@ -25,5 +25,9 @@ export const apiFailureResponseInterceptor = async ( } } - return Promise.reject(error); + if (error?.response?.data.responseMeta) { + return Promise.resolve(error.response.data); + } + + return Promise.resolve(error); }; diff --git a/app/client/src/sagas/DatasourcesSagas.ts b/app/client/src/sagas/DatasourcesSagas.ts index bbe7de399c48..40cff1e2c294 100644 --- a/app/client/src/sagas/DatasourcesSagas.ts +++ b/app/client/src/sagas/DatasourcesSagas.ts @@ -401,7 +401,9 @@ function* handleDatasourceDeleteRedirect(deletedDatasourceId: string) { // Go to the add datasource if the last item is deleted if (remainingDatasources.length === 0) { - history.push(integrationEditorURL({ selectedTab: INTEGRATION_TABS.NEW })); + yield call(() => + history.push(integrationEditorURL({ selectedTab: INTEGRATION_TABS.NEW })), + ); return; } From 967906ac8db4d3dfed4c77ccbe745d4c4553f21f Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Mon, 23 Sep 2024 17:21:06 +0530 Subject: [PATCH 6/8] update tests --- .../apiFailureResponseInterceptors.ts | 20 +++++++++++-------- .../api/__tests__/apiRequestInterceptors.ts | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/client/src/api/__tests__/apiFailureResponseInterceptors.ts b/app/client/src/api/__tests__/apiFailureResponseInterceptors.ts index 44d765ff2b52..7802d608b81e 100644 --- a/app/client/src/api/__tests__/apiFailureResponseInterceptors.ts +++ b/app/client/src/api/__tests__/apiFailureResponseInterceptors.ts @@ -9,6 +9,7 @@ import type { ApiResponse } from "api/types"; import { createMessage, ERROR_0, + ERROR_413, ERROR_500, SERVER_API_TIMEOUT_ERROR, } from "ee/constants/messages"; @@ -25,20 +26,23 @@ describe("Api success response interceptors", () => { it("checks 413 error", async () => { axios.defaults.adapter = async () => { - return new Promise((resolve, reject) => { - reject({ - response: { - status: 413, - statusText: "Request Entity Too Large", - }, - } as AxiosError); - }); + return Promise.reject({ + response: { + status: 413, + }, + } as AxiosError); }; try { await axios.get("https://example.com"); } catch (error) { expect((error as AxiosError).response?.status).toBe(413); + expect((error as AxiosError).message).toBe( + createMessage(ERROR_413, 100), + ); + expect( + (error as AxiosError & { statusCode?: string }).statusCode, + ).toBe("AE-APP-4013"); } axios.defaults.adapter = undefined; diff --git a/app/client/src/api/__tests__/apiRequestInterceptors.ts b/app/client/src/api/__tests__/apiRequestInterceptors.ts index b69eeed5af65..8a90efc3d4ba 100644 --- a/app/client/src/api/__tests__/apiRequestInterceptors.ts +++ b/app/client/src/api/__tests__/apiRequestInterceptors.ts @@ -95,7 +95,7 @@ describe("Api request interceptors", () => { axios.interceptors.request.eject(identifier); }); - it("checks if the request config has airgapped in the headers", async () => { + it("checks if request is 200 ok when isAirgapped is true", async () => { const url = "v1/saas"; const identifier = axios.interceptors.request.use((config) => { return blockAirgappedRoutes(config, { isAirgapped: true }); From 88dc351652e65feabccc9536f97e6ffa0134df00 Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Wed, 25 Sep 2024 13:07:37 +0530 Subject: [PATCH 7/8] add comment for why we pass 2nd second parameter in api functions --- app/client/src/api/core/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/client/src/api/core/api.ts b/app/client/src/api/core/api.ts index 6b81c64e9a05..fd0fe8358f57 100644 --- a/app/client/src/api/core/api.ts +++ b/app/client/src/api/core/api.ts @@ -5,6 +5,8 @@ import { apiFactory } from "./factory"; const apiInstance = apiFactory(); export async function get(...args: Parameters) { + // Note: we are passing AxiosResponseData as the second type argument to set the default type of the response data. + // The reason is we modify the response data in the response interceptor and we want to make sure that the response data's type matches what we do in the interceptor. return apiInstance.get>(...args); } From ed537d3958a3eba4502cbc32daf60c4cd814002d Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Wed, 25 Sep 2024 13:10:55 +0530 Subject: [PATCH 8/8] add comment for why we pass 2nd second parameter in api functions --- app/client/src/api/core/api.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/client/src/api/core/api.ts b/app/client/src/api/core/api.ts index fd0fe8358f57..ecfb9e74f099 100644 --- a/app/client/src/api/core/api.ts +++ b/app/client/src/api/core/api.ts @@ -5,8 +5,9 @@ import { apiFactory } from "./factory"; const apiInstance = apiFactory(); export async function get(...args: Parameters) { - // Note: we are passing AxiosResponseData as the second type argument to set the default type of the response data. - // The reason is we modify the response data in the response interceptor and we want to make sure that the response data's type matches what we do in the interceptor. + // Note: we are passing AxiosResponseData as the second type argument to set the default type of the response data.The reason + // is we modify the response data in the responseSuccessInterceptor to return `.data` property from the axios response so that we can + // just `response.data` instead of `response.data.data`. So we have to make sure that the response data's type matches what we do in the interceptor. return apiInstance.get>(...args); }