From 6b38289bfc8250f61edb6541b8441acad80b8e19 Mon Sep 17 00:00:00 2001 From: Huw Wilkins Date: Thu, 1 Aug 2024 15:41:33 +1000 Subject: [PATCH 1/3] refactor: migrate API calls to Axios --- ui/src/api/client.tsx | 69 ++++++++++++--------------------------- ui/src/api/identities.tsx | 69 +++++++++++---------------------------- ui/src/api/provider.tsx | 63 +++++++++-------------------------- ui/src/api/schema.tsx | 66 ++++++++++--------------------------- ui/src/index.tsx | 5 ++- ui/src/util/api.tsx | 18 ++++++---- 6 files changed, 88 insertions(+), 202 deletions(-) diff --git a/ui/src/api/client.tsx b/ui/src/api/client.tsx index 3282f5eb6..b8305c656 100644 --- a/ui/src/api/client.tsx +++ b/ui/src/api/client.tsx @@ -1,61 +1,32 @@ import { Client } from "types/client"; -import { ApiResponse, PaginatedResponse } from "types/api"; -import { handleResponse, PAGE_SIZE } from "util/api"; -import { apiBasePath } from "util/basePaths"; +import { PaginatedResponse } from "types/api"; +import { handleRequest, PAGE_SIZE } from "util/api"; +import axios from "axios"; export const fetchClients = ( pageToken: string, -): Promise> => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}clients?page_token=${pageToken}&size=${PAGE_SIZE}`) - .then(handleResponse) - .then((result: PaginatedResponse) => resolve(result)) - .catch(reject); - }); -}; +): Promise> => + handleRequest(() => + axios.get>( + `/clients?page_token=${pageToken}&size=${PAGE_SIZE}`, + ), + ); -export const fetchClient = (clientId: string): Promise => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}clients/${clientId}`) - .then(handleResponse) - .then((result: ApiResponse) => resolve(result.data)) - .catch(reject); - }); -}; +export const fetchClient = (clientId: string): Promise => + handleRequest(() => axios.get(`/clients/${clientId}`)); -export const createClient = (values: string): Promise => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}clients`, { - method: "POST", - body: values, - }) - .then(handleResponse) - .then((result: ApiResponse) => resolve(result.data)) - .catch(reject); - }); -}; +export const createClient = (values: string): Promise => + handleRequest(() => axios.post("/clients", values)); export const updateClient = ( clientId: string, values: string, -): Promise => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}clients/${clientId}`, { - method: "PUT", - body: values, - }) - .then(handleResponse) - .then((result: ApiResponse) => resolve(result.data)) - .catch(reject); - }); -}; +): Promise => + handleRequest(() => axios.post(`/clients/${clientId}`, values)); -export const deleteClient = (client: string) => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}clients/${client}`, { +export const deleteClient = (client: string) => + handleRequest(() => + axios.get(`/clients/${client}`, { method: "DELETE", - }) - .then(resolve) - .catch(reject); - }); -}; + }), + ); diff --git a/ui/src/api/identities.tsx b/ui/src/api/identities.tsx index ff9771b73..c363dad01 100644 --- a/ui/src/api/identities.tsx +++ b/ui/src/api/identities.tsx @@ -1,61 +1,30 @@ -import { ApiResponse, PaginatedResponse } from "types/api"; -import { handleResponse, PAGE_SIZE } from "util/api"; +import { PaginatedResponse } from "types/api"; +import { handleRequest, PAGE_SIZE } from "util/api"; import { Identity } from "types/identity"; -import { apiBasePath } from "util/basePaths"; +import axios from "axios"; export const fetchIdentities = ( pageToken: string, -): Promise> => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}identities?page_token=${pageToken}&size=${PAGE_SIZE}`) - .then(handleResponse) - .then((result: PaginatedResponse) => resolve(result)) - .catch(reject); - }); -}; +): Promise> => + handleRequest(() => + axios.get>( + `/identities?page_token=${pageToken}&size=${PAGE_SIZE}`, + ), + ); -export const fetchIdentity = (identityId: string): Promise => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}identities/${identityId}`) - .then(handleResponse) - .then((result: ApiResponse) => resolve(result.data[0])) - .catch(reject); - }); -}; +export const fetchIdentity = (identityId: string): Promise => + handleRequest(() => axios.get(`/identities/${identityId}`)); -export const createIdentity = (body: string): Promise => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}identities`, { - method: "POST", - body: body, - }) - .then(handleResponse) - .then(resolve) - .catch(reject); - }); -}; +export const createIdentity = (body: string): Promise => + handleRequest(() => axios.post("/identities", body)); export const updateIdentity = ( identityId: string, values: string, -): Promise => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}identities/${identityId}`, { - method: "PATCH", - body: values, - }) - .then(handleResponse) - .then((result: ApiResponse) => resolve(result.data)) - .catch(reject); - }); -}; +): Promise => + handleRequest(() => + axios.patch(`/identities/${identityId}`, values), + ); -export const deleteIdentity = (identityId: string) => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}identities/${identityId}`, { - method: "DELETE", - }) - .then(resolve) - .catch(reject); - }); -}; +export const deleteIdentity = (identityId: string): Promise => + handleRequest(() => axios.delete(`/identities/${identityId}`)); diff --git a/ui/src/api/provider.tsx b/ui/src/api/provider.tsx index f469bb837..12b356cdd 100644 --- a/ui/src/api/provider.tsx +++ b/ui/src/api/provider.tsx @@ -1,63 +1,30 @@ -import { ApiResponse } from "types/api"; -import { handleResponse } from "util/api"; +import { handleRequest } from "util/api"; import { IdentityProvider } from "types/provider"; -import { apiBasePath } from "util/basePaths"; +import axios from "axios"; -export const fetchProviders = (): Promise => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}idps`) - .then(handleResponse) - .then((result: ApiResponse) => resolve(result.data)) - .catch(reject); - }); -}; +export const fetchProviders = (): Promise => + handleRequest(() => axios.get(`/idps`)); export const fetchProvider = ( providerId: string, ): Promise => { return new Promise((resolve, reject) => { - fetch(`${apiBasePath}idps/${providerId}`) - .then(handleResponse) - .then((result: ApiResponse) => - resolve(result.data[0]), - ) - .catch(reject); + handleRequest(() => axios.get(`/idps/${providerId}`)) + .then((data) => resolve(data[0])) + .catch((error) => reject(error)); }); }; -export const createProvider = (body: string): Promise => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}idps`, { - method: "POST", - body: body, - }) - .then(handleResponse) - .then(resolve) - .catch(reject); - }); -}; +export const createProvider = (body: string): Promise => + handleRequest(() => axios.post("/idps", body)); export const updateProvider = ( providerId: string, values: string, -): Promise => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}idps/${providerId}`, { - method: "PATCH", - body: values, - }) - .then(handleResponse) - .then((result: ApiResponse) => resolve(result.data)) - .catch(reject); - }); -}; +): Promise => + handleRequest(() => + axios.patch(`/idps/${providerId}`, values), + ); -export const deleteProvider = (providerId: string) => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}idps/${providerId}`, { - method: "DELETE", - }) - .then(resolve) - .catch(reject); - }); -}; +export const deleteProvider = (providerId: string): Promise => + handleRequest(() => axios.delete(`/idps/${providerId}`)); diff --git a/ui/src/api/schema.tsx b/ui/src/api/schema.tsx index 543adfbdb..081d1d86f 100644 --- a/ui/src/api/schema.tsx +++ b/ui/src/api/schema.tsx @@ -1,63 +1,33 @@ -import { ApiResponse, PaginatedResponse } from "types/api"; -import { handleResponse, PAGE_SIZE } from "util/api"; +import { PaginatedResponse } from "types/api"; +import { handleRequest, PAGE_SIZE } from "util/api"; import { Schema } from "types/schema"; -import { apiBasePath } from "util/basePaths"; +import axios from "axios"; export const fetchSchemas = ( pageToken: string, -): Promise> => { - return new Promise((resolve, reject) => { - fetch( - `${apiBasePath}schemas?page_token=${pageToken}&page_size=${PAGE_SIZE}`, - ) - .then(handleResponse) - .then((result: PaginatedResponse) => resolve(result)) - .catch(reject); - }); -}; +): Promise> => + handleRequest(() => + axios.get>( + `/schemas?page_token=${pageToken}&page_size=${PAGE_SIZE}`, + ), + ); export const fetchSchema = (schemaId: string): Promise => { return new Promise((resolve, reject) => { - fetch(`${apiBasePath}schemas/${schemaId}`) - .then(handleResponse) - .then((result: ApiResponse) => resolve(result.data[0])) - .catch(reject); + handleRequest(() => axios.get(`/schemas/${schemaId}`)) + .then((data) => resolve(data[0])) + .catch((error) => reject(error)); }); }; -export const createSchema = (body: string): Promise => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}schemas`, { - method: "POST", - body: body, - }) - .then(handleResponse) - .then(resolve) - .catch(reject); - }); -}; +export const createSchema = (body: string): Promise => + handleRequest(() => axios.post("/schemas", body)); export const updateSchema = ( schemaId: string, values: string, -): Promise => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}schemas/${schemaId}`, { - method: "PATCH", - body: values, - }) - .then(handleResponse) - .then((result: ApiResponse) => resolve(result.data)) - .catch(reject); - }); -}; +): Promise => + handleRequest(() => axios.patch(`/schemas/${schemaId}`, values)); -export const deleteSchema = (schemaId: string) => { - return new Promise((resolve, reject) => { - fetch(`${apiBasePath}schemas/${schemaId}`, { - method: "DELETE", - }) - .then(resolve) - .catch(reject); - }); -}; +export const deleteSchema = (schemaId: string): Promise => + handleRequest(() => axios.delete(`/schemas/${schemaId}`)); diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 7afedf917..5c1c7191e 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -4,7 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import App from "./App"; import "./sass/styles.scss"; import { NotificationProvider } from "@canonical/react-components"; -import { basePath } from "util/basePaths"; +import { apiBasePath, basePath } from "util/basePaths"; +import axios from "axios"; const queryClient = new QueryClient({ defaultOptions: { @@ -17,6 +18,8 @@ const queryClient = new QueryClient({ }, }); +axios.defaults.baseURL = apiBasePath; + const rootElement = document.getElementById("app"); if (!rootElement) throw new Error("Failed to find the root element"); const root = createRoot(rootElement); diff --git a/ui/src/util/api.tsx b/ui/src/util/api.tsx index cc899b8f8..c78cdef13 100644 --- a/ui/src/util/api.tsx +++ b/ui/src/util/api.tsx @@ -1,11 +1,17 @@ import { ErrorResponse } from "types/api"; +import { AxiosResponse } from "axios"; +import { AxiosError } from "axios"; export const PAGE_SIZE = 50; -export const handleResponse = async (response: Response) => { - if (!response.ok) { - const result = (await response.json()) as ErrorResponse; - throw Error(result.error ?? result.message); - } - return response.json(); +export const handleRequest = ( + request: () => Promise>, +): Promise => { + return new Promise((resolve, reject) => { + request() + .then((result) => resolve(result.data)) + .catch(({ response }: AxiosError) => + reject(response?.data?.error ?? response?.data?.message), + ); + }); }; From 1cd4b98efe2262958155a186b7ac59431875aaa8 Mon Sep 17 00:00:00 2001 From: Huw Wilkins Date: Fri, 2 Aug 2024 11:40:54 +1000 Subject: [PATCH 2/3] fix: annotate responses with the full type --- ui/src/api/client.tsx | 18 ++++++++------- ui/src/api/identities.tsx | 18 +++++++++------ ui/src/api/provider.tsx | 23 +++++++++++++------- ui/src/api/schema.tsx | 18 +++++++++------ ui/src/pages/clients/ClientCreate.tsx | 2 +- ui/src/pages/clients/ClientEdit.tsx | 3 ++- ui/src/pages/providers/DeleteProviderBtn.tsx | 4 ++++ ui/src/pages/providers/ProviderList.tsx | 4 ++-- ui/src/util/api.tsx | 6 ++--- 9 files changed, 59 insertions(+), 37 deletions(-) diff --git a/ui/src/api/client.tsx b/ui/src/api/client.tsx index b8305c656..bf4c3a619 100644 --- a/ui/src/api/client.tsx +++ b/ui/src/api/client.tsx @@ -1,5 +1,5 @@ import { Client } from "types/client"; -import { PaginatedResponse } from "types/api"; +import { PaginatedResponse, ApiResponse } from "types/api"; import { handleRequest, PAGE_SIZE } from "util/api"; import axios from "axios"; @@ -12,21 +12,23 @@ export const fetchClients = ( ), ); -export const fetchClient = (clientId: string): Promise => - handleRequest(() => axios.get(`/clients/${clientId}`)); +export const fetchClient = (clientId: string): Promise> => + handleRequest(() => axios.get>(`/clients/${clientId}`)); -export const createClient = (values: string): Promise => - handleRequest(() => axios.post("/clients", values)); +export const createClient = (values: string): Promise> => + handleRequest(() => axios.post>("/clients", values)); export const updateClient = ( clientId: string, values: string, -): Promise => - handleRequest(() => axios.post(`/clients/${clientId}`, values)); +): Promise> => + handleRequest(() => + axios.post>(`/clients/${clientId}`, values), + ); export const deleteClient = (client: string) => handleRequest(() => - axios.get(`/clients/${client}`, { + axios.get>(`/clients/${client}`, { method: "DELETE", }), ); diff --git a/ui/src/api/identities.tsx b/ui/src/api/identities.tsx index c363dad01..375835182 100644 --- a/ui/src/api/identities.tsx +++ b/ui/src/api/identities.tsx @@ -1,4 +1,4 @@ -import { PaginatedResponse } from "types/api"; +import { ApiResponse, PaginatedResponse } from "types/api"; import { handleRequest, PAGE_SIZE } from "util/api"; import { Identity } from "types/identity"; import axios from "axios"; @@ -12,19 +12,23 @@ export const fetchIdentities = ( ), ); -export const fetchIdentity = (identityId: string): Promise => - handleRequest(() => axios.get(`/identities/${identityId}`)); +export const fetchIdentity = ( + identityId: string, +): Promise> => + handleRequest(() => + axios.get>(`/identities/${identityId}`), + ); -export const createIdentity = (body: string): Promise => +export const createIdentity = (body: string): Promise => handleRequest(() => axios.post("/identities", body)); export const updateIdentity = ( identityId: string, values: string, -): Promise => +): Promise> => handleRequest(() => - axios.patch(`/identities/${identityId}`, values), + axios.patch>(`/identities/${identityId}`, values), ); -export const deleteIdentity = (identityId: string): Promise => +export const deleteIdentity = (identityId: string): Promise => handleRequest(() => axios.delete(`/identities/${identityId}`)); diff --git a/ui/src/api/provider.tsx b/ui/src/api/provider.tsx index 12b356cdd..db8f77289 100644 --- a/ui/src/api/provider.tsx +++ b/ui/src/api/provider.tsx @@ -1,30 +1,37 @@ import { handleRequest } from "util/api"; import { IdentityProvider } from "types/provider"; import axios from "axios"; +import { PaginatedResponse, ApiResponse } from "types/api"; -export const fetchProviders = (): Promise => - handleRequest(() => axios.get(`/idps`)); +export const fetchProviders = (): Promise< + PaginatedResponse +> => + handleRequest(() => + axios.get>(`/idps`), + ); export const fetchProvider = ( providerId: string, ): Promise => { return new Promise((resolve, reject) => { - handleRequest(() => axios.get(`/idps/${providerId}`)) - .then((data) => resolve(data[0])) + handleRequest>(() => + axios.get>(`/idps/${providerId}`), + ) + .then(({ data }) => resolve(data[0])) .catch((error) => reject(error)); }); }; -export const createProvider = (body: string): Promise => +export const createProvider = (body: string): Promise => handleRequest(() => axios.post("/idps", body)); export const updateProvider = ( providerId: string, values: string, -): Promise => +): Promise> => handleRequest(() => - axios.patch(`/idps/${providerId}`, values), + axios.patch>(`/idps/${providerId}`, values), ); -export const deleteProvider = (providerId: string): Promise => +export const deleteProvider = (providerId: string): Promise => handleRequest(() => axios.delete(`/idps/${providerId}`)); diff --git a/ui/src/api/schema.tsx b/ui/src/api/schema.tsx index 081d1d86f..928454db5 100644 --- a/ui/src/api/schema.tsx +++ b/ui/src/api/schema.tsx @@ -1,4 +1,4 @@ -import { PaginatedResponse } from "types/api"; +import { PaginatedResponse, ApiResponse } from "types/api"; import { handleRequest, PAGE_SIZE } from "util/api"; import { Schema } from "types/schema"; import axios from "axios"; @@ -14,20 +14,24 @@ export const fetchSchemas = ( export const fetchSchema = (schemaId: string): Promise => { return new Promise((resolve, reject) => { - handleRequest(() => axios.get(`/schemas/${schemaId}`)) - .then((data) => resolve(data[0])) + handleRequest>(() => + axios.get>(`/schemas/${schemaId}`), + ) + .then(({ data }) => resolve(data[0])) .catch((error) => reject(error)); }); }; -export const createSchema = (body: string): Promise => +export const createSchema = (body: string): Promise => handleRequest(() => axios.post("/schemas", body)); export const updateSchema = ( schemaId: string, values: string, -): Promise => - handleRequest(() => axios.patch(`/schemas/${schemaId}`, values)); +): Promise> => + handleRequest(() => + axios.patch>(`/schemas/${schemaId}`, values), + ); -export const deleteSchema = (schemaId: string): Promise => +export const deleteSchema = (schemaId: string): Promise => handleRequest(() => axios.delete(`/schemas/${schemaId}`)); diff --git a/ui/src/pages/clients/ClientCreate.tsx b/ui/src/pages/clients/ClientCreate.tsx index faebe667d..63ce27538 100644 --- a/ui/src/pages/clients/ClientCreate.tsx +++ b/ui/src/pages/clients/ClientCreate.tsx @@ -38,7 +38,7 @@ const ClientCreate: FC = () => { validationSchema: ClientCreateSchema, onSubmit: (values) => { createClient(JSON.stringify(values)) - .then((result) => { + .then(({ data: result }) => { void queryClient.invalidateQueries({ queryKey: [queryKeys.clients], }); diff --git a/ui/src/pages/clients/ClientEdit.tsx b/ui/src/pages/clients/ClientEdit.tsx index df5fef2ee..5d7b42c11 100644 --- a/ui/src/pages/clients/ClientEdit.tsx +++ b/ui/src/pages/clients/ClientEdit.tsx @@ -28,10 +28,11 @@ const ClientEdit: FC = () => { return; } - const { data: client } = useQuery({ + const { data } = useQuery({ queryKey: [queryKeys.clients, clientId], queryFn: () => fetchClient(clientId), }); + const client = data?.data; const ClientEditSchema = Yup.object().shape({ client_name: Yup.string().required("This field is required"), diff --git a/ui/src/pages/providers/DeleteProviderBtn.tsx b/ui/src/pages/providers/DeleteProviderBtn.tsx index 910cfcabe..bc660f81b 100644 --- a/ui/src/pages/providers/DeleteProviderBtn.tsx +++ b/ui/src/pages/providers/DeleteProviderBtn.tsx @@ -28,6 +28,10 @@ const DeleteProviderBtn: FC = ({ provider }) => { const handleDelete = () => { setLoading(true); + if (!provider.id) { + console.error("Cannot delete provider without id", provider); + return; + } deleteProvider(provider.id) .then(() => { navigate( diff --git a/ui/src/pages/providers/ProviderList.tsx b/ui/src/pages/providers/ProviderList.tsx index 9698ab053..1fc53293f 100644 --- a/ui/src/pages/providers/ProviderList.tsx +++ b/ui/src/pages/providers/ProviderList.tsx @@ -16,7 +16,7 @@ import DeleteProviderBtn from "pages/providers/DeleteProviderBtn"; const ProviderList: FC = () => { const panelParams = usePanelParams(); - const { data: providers = [] } = useQuery({ + const { data: providers } = useQuery({ queryKey: [queryKeys.providers], queryFn: fetchProviders, }); @@ -51,7 +51,7 @@ const ProviderList: FC = () => { { content: "Provider", sortKey: "provider" }, { content: "Actions" }, ]} - rows={providers.map((provider) => { + rows={providers?.data.map((provider) => { return { columns: [ { diff --git a/ui/src/util/api.tsx b/ui/src/util/api.tsx index c78cdef13..4009aac2c 100644 --- a/ui/src/util/api.tsx +++ b/ui/src/util/api.tsx @@ -1,15 +1,15 @@ -import { ErrorResponse } from "types/api"; +import { ApiResponse, ErrorResponse } from "types/api"; import { AxiosResponse } from "axios"; import { AxiosError } from "axios"; export const PAGE_SIZE = 50; -export const handleRequest = ( +export const handleRequest = >( request: () => Promise>, ): Promise => { return new Promise((resolve, reject) => { request() - .then((result) => resolve(result.data)) + .then((response) => resolve(response.data)) .catch(({ response }: AxiosError) => reject(response?.data?.error ?? response?.data?.message), ); From 5fefbd7726e174e64e2df7fdf69ceaa2e42eb97e Mon Sep 17 00:00:00 2001 From: Huw Wilkins Date: Wed, 7 Aug 2024 14:27:18 +1000 Subject: [PATCH 3/3] refactor: change tests to mock axios requests --- ui/package.json | 4 +- ui/src/api/auth.test.ts | 15 ++++--- ui/src/api/auth.ts | 25 +++++++----- ui/src/components/Layout/Layout.test.tsx | 17 +++++--- ui/src/components/Login/Login.tsx | 3 +- ui/src/test/setup.ts | 6 --- ui/src/util/basePaths.spec.ts | 36 +++++++++++++++- ui/src/util/basePaths.ts | 6 +++ ui/yarn.lock | 52 ++++++------------------ 9 files changed, 95 insertions(+), 69 deletions(-) diff --git a/ui/package.json b/ui/package.json index d06adfcce..c8c76bd26 100644 --- a/ui/package.json +++ b/ui/package.json @@ -53,6 +53,7 @@ "@typescript-eslint/parser": "7.3.1", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "10.4.19", + "axios-mock-adapter": "2.0.0", "babel-plugin-transform-es2015-modules-commonjs": "6.26.2", "concurrently": "8.2.2", "eslint": "8.57.0", @@ -75,8 +76,7 @@ "typescript": "5.4.3", "vite": "5.2.8", "vite-tsconfig-paths": "4.3.2", - "vitest": "1.2.1", - "vitest-fetch-mock": "0.3.0" + "vitest": "1.2.1" }, "lint-staged": { "src/**/*.{json,jsx,ts,tsx}": [ diff --git a/ui/src/api/auth.test.ts b/ui/src/api/auth.test.ts index a2424b187..e943c4367 100644 --- a/ui/src/api/auth.test.ts +++ b/ui/src/api/auth.test.ts @@ -1,7 +1,12 @@ -import { fetchMe } from "./auth"; +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; + +import { fetchMe, authURLs } from "./auth"; + +const mock = new MockAdapter(axios); beforeEach(() => { - fetchMock.resetMocks(); + mock.reset(); }); test("fetches a user", async () => { @@ -12,17 +17,17 @@ test("fetches a user", async () => { sid: "sid", sub: "sub", }; - fetchMock.mockResponse(JSON.stringify(user), { status: 200 }); + mock.onGet(authURLs.me).reply(200, user); await expect(fetchMe()).resolves.toStrictEqual(user); }); test("handles a non-authenticated user", async () => { - fetchMock.mockResponseOnce(JSON.stringify({}), { status: 401 }); + mock.onGet(authURLs.me).reply(401, {}); await expect(fetchMe()).resolves.toBeNull(); }); test("catches errors", async () => { const error = "Uh oh!"; - fetchMock.mockRejectedValue(error); + mock.onGet(authURLs.me).reply(500, { error }); await expect(fetchMe()).rejects.toBe(error); }); diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index efdca2899..7af2f07b2 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -1,24 +1,29 @@ -import { apiBasePath } from "util/basePaths"; +import axios from "axios"; + import type { UserPrincipal } from "types/auth"; -import { handleResponse } from "util/api"; +import { ErrorResponse } from "types/api"; +import { AxiosError } from "axios"; -const BASE = `${apiBasePath}auth`; +const BASE = "auth"; export const authURLs = { login: BASE, me: `${BASE}/me`, }; -export const fetchMe = (): Promise => { +export const fetchMe = (): Promise => { return new Promise((resolve, reject) => { - fetch(authURLs.me) - .then((response: Response) => + axios + .get(authURLs.me) + .then(({ data }) => resolve(data)) + .catch(({ response }: AxiosError) => { // If the user is not authenticated then return null instead of throwing an // error. This is necessary so that a login screen can be displayed instead of displaying // the error. - [401, 403].includes(response.status) ? null : handleResponse(response), - ) - .then((result: UserPrincipal) => resolve(result)) - .catch(reject); + if (response?.status && [401, 403].includes(response.status)) { + resolve(null); + } + return reject(response?.data?.error ?? response?.data?.message); + }); }); }; diff --git a/ui/src/components/Layout/Layout.test.tsx b/ui/src/components/Layout/Layout.test.tsx index 4040a201f..a82d20513 100644 --- a/ui/src/components/Layout/Layout.test.tsx +++ b/ui/src/components/Layout/Layout.test.tsx @@ -1,14 +1,21 @@ -import { renderComponent } from "test/utils"; -import Layout from "./Layout"; import { screen } from "@testing-library/dom"; +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; + +import { authURLs } from "api/auth"; import { LoginLabel } from "components/Login"; +import { renderComponent } from "test/utils"; + +import Layout from "./Layout"; + +const mock = new MockAdapter(axios); beforeEach(() => { - fetchMock.resetMocks(); + mock.reset(); }); test("displays the login screen if the user is not authenticated", async () => { - fetchMock.mockResponseOnce(JSON.stringify({}), { status: 403 }); + mock.onGet(authURLs.me).reply(403, {}); renderComponent(); expect( await screen.findByRole("heading", { name: LoginLabel.TITLE }), @@ -23,7 +30,7 @@ test("displays the layout and content if the user is authenticated", async () => sid: "sid", sub: "sub", }; - fetchMock.mockResponse(JSON.stringify(user), { status: 200 }); + mock.onGet(authURLs.me).reply(200, { data: user }); renderComponent(, { path: "/", url: "/", diff --git a/ui/src/components/Login/Login.tsx b/ui/src/components/Login/Login.tsx index c7a7baaea..71aa5100e 100644 --- a/ui/src/components/Login/Login.tsx +++ b/ui/src/components/Login/Login.tsx @@ -9,6 +9,7 @@ import { authURLs } from "api/auth"; import { FC, ReactNode } from "react"; import { SITE_NAME } from "consts"; import { Label } from "./types"; +import { appendAPIBasePath } from "util/basePaths"; type Props = { isLoading?: boolean; @@ -38,7 +39,7 @@ const Login: FC = ({ error, isLoading }) => { } disabled={isLoading} element="a" - href={authURLs.login} + href={appendAPIBasePath(authURLs.login)} > Sign in to {SITE_NAME} diff --git a/ui/src/test/setup.ts b/ui/src/test/setup.ts index 4a924a022..1622d2e97 100644 --- a/ui/src/test/setup.ts +++ b/ui/src/test/setup.ts @@ -1,12 +1,6 @@ import "@testing-library/jest-dom/vitest"; -import { vi } from "vitest"; -import createFetchMock from "vitest-fetch-mock"; import type { Window as HappyDOMWindow } from "happy-dom"; declare global { interface Window extends HappyDOMWindow {} } - -const fetchMocker = createFetchMock(vi); -// sets globalThis.fetch and globalThis.fetchMock to our mocked version -fetchMocker.enableMocks(); diff --git a/ui/src/util/basePaths.spec.ts b/ui/src/util/basePaths.spec.ts index c3d1a9671..adaf21769 100644 --- a/ui/src/util/basePaths.spec.ts +++ b/ui/src/util/basePaths.spec.ts @@ -1,4 +1,18 @@ -import { calculateBasePath } from "./basePaths"; +import { + appendBasePath, + calculateBasePath, + appendAPIBasePath, +} from "./basePaths"; + +vi.mock("./basePaths", async () => { + vi.stubGlobal("location", { pathname: "/example/ui/" }); + const actual = await vi.importActual("./basePaths"); + return { + ...actual, + basePath: "/example/ui/", + apiBasePath: "/example/ui/../api/v0/", + }; +}); describe("calculateBasePath", () => { it("resolves with ui path", () => { @@ -31,3 +45,23 @@ describe("calculateBasePath", () => { expect(result).toBe("/"); }); }); + +describe("appendBasePath", () => { + it("handles paths with a leading slash", () => { + expect(appendBasePath("/test")).toBe("/example/ui/test"); + }); + + it("handles paths without a leading slash", () => { + expect(appendBasePath("test")).toBe("/example/ui/test"); + }); +}); + +describe("appendAPIBasePath", () => { + it("handles paths with a leading slash", () => { + expect(appendAPIBasePath("/test")).toBe("/example/ui/../api/v0/test"); + }); + + it("handles paths without a leading slash", () => { + expect(appendAPIBasePath("test")).toBe("/example/ui/../api/v0/test"); + }); +}); diff --git a/ui/src/util/basePaths.ts b/ui/src/util/basePaths.ts index c85543a44..ae6f2127a 100644 --- a/ui/src/util/basePaths.ts +++ b/ui/src/util/basePaths.ts @@ -12,3 +12,9 @@ export const calculateBasePath = (): BasePath => { export const basePath: BasePath = calculateBasePath(); export const apiBasePath: BasePath = `${basePath}../api/v0/`; + +export const appendBasePath = (path: string) => + `${basePath.replace(/\/$/, "")}/${path.replace(/^\//, "")}`; + +export const appendAPIBasePath = (path: string) => + `${apiBasePath.replace(/\/$/, "")}/${path.replace(/^\//, "")}`; diff --git a/ui/yarn.lock b/ui/yarn.lock index d2a85f5f3..45ad94db9 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -2511,6 +2511,14 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" +axios-mock-adapter@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/axios-mock-adapter/-/axios-mock-adapter-2.0.0.tgz#91920c06e2e5733dd118ba079e4a12c58f3a68aa" + integrity sha512-D/K0J5Zm6KvaMTnsWrBQZWLzKN9GxUFZEa0mx2qeEHXDeTugCoplWehy8y36dj5vuSjhe1u/Dol8cZ8lzzmDew== + dependencies: + fast-deep-equal "^3.1.3" + is-buffer "^2.0.5" + axios@1.6.8: version "1.6.8" resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66" @@ -3023,13 +3031,6 @@ cosmiconfig@^9.0.0: js-yaml "^4.1.0" parse-json "^5.2.0" -cross-fetch@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983" - integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g== - dependencies: - node-fetch "^2.6.12" - cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -4421,6 +4422,11 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-buffer@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" + integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== + is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" @@ -5145,13 +5151,6 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== -node-fetch@^2.6.12: - version "2.7.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" - integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== - dependencies: - whatwg-url "^5.0.0" - node-releases@^2.0.13: version "2.0.13" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d" @@ -6609,11 +6608,6 @@ tr46@^3.0.0: dependencies: punycode "^2.1.1" -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== - tree-kill@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" @@ -6900,13 +6894,6 @@ vite@^5.0.0: optionalDependencies: fsevents "~2.3.3" -vitest-fetch-mock@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/vitest-fetch-mock/-/vitest-fetch-mock-0.3.0.tgz#a519293b61be1ce43377d720500bbfa89861e509" - integrity sha512-g6upWcL8/32fXL43/5f4VHcocuwQIi9Fj5othcK9gPO8XqSEGtnIZdenr2IaipDr61ReRFt+vaOEgo8jiUUX5w== - dependencies: - cross-fetch "^4.0.0" - vitest@1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.2.1.tgz#9afb705826a2c6260a71b625d28b49117833dce6" @@ -6941,11 +6928,6 @@ w3c-xmlserializer@^4.0.0: dependencies: xml-name-validator "^4.0.0" -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== - webidl-conversions@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" @@ -6971,14 +6953,6 @@ whatwg-url@^11.0.0: tr46 "^3.0.0" webidl-conversions "^7.0.0" -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" - which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"