From c54d3832a3a85bea8333c30e07bda3de3bd30b21 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Wed, 21 Aug 2024 10:20:12 -0500 Subject: [PATCH] Major refactor of auth, refresh, and api calls. --- src/AuthAwareQueryClientProvider.tsx | 93 ---------------------------- src/{auth.tsx => AuthProvider.tsx} | 4 +- src/RefreshProvider.tsx | 73 ++++++++++++++++++++++ src/RouterContext.ts | 2 +- src/api/apiCallFactory.ts | 62 +++++++++++++++++++ src/api/getRecipeInfos.ts | 74 ++++------------------ src/api/types/RecipeInfoView.ts | 37 +++++++++-- src/api/types/RecipeInfosView.ts | 8 ++- src/components/header/Header.tsx | 2 +- src/main.tsx | 38 +++++++++++- src/pages/edit-recipe/EditRecipe.tsx | 2 +- src/pages/recipe/Recipe.tsx | 2 +- src/pages/recipes/Recipes.tsx | 9 ++- src/routes/__root.tsx | 2 +- src/routes/login.tsx | 2 +- src/types/Refresh.ts | 7 +++ 16 files changed, 244 insertions(+), 173 deletions(-) delete mode 100644 src/AuthAwareQueryClientProvider.tsx rename src/{auth.tsx => AuthProvider.tsx} (91%) create mode 100644 src/RefreshProvider.tsx create mode 100644 src/api/apiCallFactory.ts create mode 100644 src/types/Refresh.ts diff --git a/src/AuthAwareQueryClientProvider.tsx b/src/AuthAwareQueryClientProvider.tsx deleted file mode 100644 index 3fde344..0000000 --- a/src/AuthAwareQueryClientProvider.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { ReactQueryDevtools } from '@tanstack/react-query-devtools' -import { useRouter } from '@tanstack/react-router' -import React, { useCallback, useMemo, useRef } from 'react' -import { ApiError } from './api/ApiError' -import ExpiredTokenError from './api/ExpiredTokenError' -import refresh, { RefreshTokenError } from './api/refresh' -import { useAuth } from './auth' - -const AuthAwareQueryClientProvider = ({ children }: React.PropsWithChildren) => { - const { putToken } = useAuth() - const router = useRouter() - const refreshing = useRef(false) - - const doRefresh = useCallback(async () => { - putToken(null) - if (!refreshing.current) { - refreshing.current = true - try { - const { accessToken: token, expires, username } = await refresh() - putToken({ - token, - expires, - username - }) - } catch (error) { - if (error instanceof RefreshTokenError) { - router.navigate({ - to: '/login', - search: { - reason: error.reason, - redirect: router.state.location.href - } - }) - } else if (error instanceof ApiError) { - console.error(error) - } - } - refreshing.current = false - } - }, [putToken, router]) - - const queryClient = useMemo( - () => - new QueryClient({ - defaultOptions: { - mutations: { - onError(error) { - if (error instanceof ExpiredTokenError) { - doRefresh() - } - } - }, - queries: { - retry(failureCount, error) { - if ( - error instanceof ExpiredTokenError || - (error instanceof ApiError && error.status === 404) - ) { - return false - } else { - return failureCount <= 3 - } - }, - retryDelay(failureCount, error) { - if (error instanceof ExpiredTokenError) { - return 0 - } else { - return failureCount * 1000 - } - } - } - }, - queryCache: new QueryCache({ - onError(error) { - if (error instanceof ExpiredTokenError) { - doRefresh() - } - } - }) - }), - [doRefresh] - ) - - return ( - - {children} - - - ) -} - -export default AuthAwareQueryClientProvider diff --git a/src/auth.tsx b/src/AuthProvider.tsx similarity index 91% rename from src/auth.tsx rename to src/AuthProvider.tsx index ba4a3c7..a288d0a 100644 --- a/src/auth.tsx +++ b/src/AuthProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useReducer } from 'react' +import { createContext, PropsWithChildren, useContext, useReducer } from 'react' import AccessToken from './types/AccessToken' export interface AuthContextType { @@ -36,7 +36,7 @@ const authReducer = (_state: AuthReducerState, action: AuthReducerAction): AuthR const AuthContext = createContext(null) -export const AuthProvider = ({ children }: React.PropsWithChildren) => { +export const AuthProvider = ({ children }: PropsWithChildren) => { const [state, dispatch] = useReducer(authReducer, initialState) return ( diff --git a/src/RefreshProvider.tsx b/src/RefreshProvider.tsx new file mode 100644 index 0000000..ef9ebde --- /dev/null +++ b/src/RefreshProvider.tsx @@ -0,0 +1,73 @@ +import { useRouter } from '@tanstack/react-router' +import { createContext, useContext, PropsWithChildren, useRef, useCallback } from 'react' +import { ApiError } from './api/ApiError' +import refresh, { RefreshTokenError } from './api/refresh' +import { useAuth } from './AuthProvider' +import Refresh from './types/Refresh' + +export interface RefreshContextType { + refresh: Refresh +} + +const RefreshContext = createContext(null) + +export const useRefresh = () => { + const refreshContext = useContext(RefreshContext) + if (refreshContext === null) { + throw new Error('refreshContext is null') + } + return refreshContext.refresh +} + +const RefreshProvider = ({ children }: PropsWithChildren) => { + const { putToken } = useAuth() + const router = useRouter() + const refreshing = useRef(false) + + const doRefresh: Refresh = useCallback(async () => { + putToken(null) + if (!refreshing.current) { + refreshing.current = true + try { + const { accessToken: token, expires, username } = await refresh() + putToken({ + token, + expires, + username + }) + refreshing.current = false + return { + token, + expires, + username + } + } catch (error) { + if (error instanceof RefreshTokenError) { + router.navigate({ + to: '/login', + search: { + reason: error.reason, + redirect: router.state.location.href + } + }) + } else if (error instanceof ApiError) { + console.error(error) + } + refreshing.current = false + } + } + return null + }, [putToken, router]) + + return ( + + {children} + + ) +} + +export default RefreshProvider diff --git a/src/RouterContext.ts b/src/RouterContext.ts index 7d71095..b8856b4 100644 --- a/src/RouterContext.ts +++ b/src/RouterContext.ts @@ -1,4 +1,4 @@ -import { AuthContextType } from './auth' +import { AuthContextType } from './AuthProvider' export default interface RouterContext { auth: AuthContextType diff --git a/src/api/apiCallFactory.ts b/src/api/apiCallFactory.ts new file mode 100644 index 0000000..3c19489 --- /dev/null +++ b/src/api/apiCallFactory.ts @@ -0,0 +1,62 @@ +import AccessToken from '../types/AccessToken' +import Refresh from '../types/Refresh' +import { ApiError } from './ApiError' + +export interface ApiCallDeps { + accessToken: AccessToken | null + endpoint: string + query?: string + refresh: Refresh + signal: AbortSignal + body?: any +} + +export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' + +const getApiCallFactory = + (method: Method) => + (handleBody?: (raw: any) => T) => + async ({ accessToken, endpoint, refresh, signal, body }: ApiCallDeps): Promise => { + const headers = new Headers() + if (accessToken) { + headers.set('Authorization', `Bearer ${accessToken.token}`) + } + if (body) { + headers.set('Content-type', 'application/json') + } + const url = import.meta.env.VITE_MME_API_URL + endpoint + const response = await fetch(url, { + body: body ? JSON.stringify(body) : undefined, + signal, + headers, + mode: 'cors', + method + }) + if (response.ok && handleBody) { + return handleBody(await response.json()) + } else if (response.status === 401) { + const newToken = await refresh() + if (newToken === null) { + throw new ApiError(401, 'Could not get a refreshed access token.') + } + headers.set('Authorization', `Bearer ${newToken.token}`) + const retry = await fetch(url, { + signal, + headers, + mode: 'cors', + method + }) + if (retry.ok && handleBody) { + return handleBody(await retry.json()) + } else { + throw new ApiError(retry.status, retry.statusText) + } + } else { + throw new ApiError(response.status, response.statusText) + } + } + +export const getCallFactory = getApiCallFactory('GET') +export const postCallFactory = getApiCallFactory('POST') +export const putCallFactory = getApiCallFactory('PUT') +export const deleteCallFactory = getApiCallFactory('DELETE') diff --git a/src/api/getRecipeInfos.ts b/src/api/getRecipeInfos.ts index 03b54ff..67d4c99 100644 --- a/src/api/getRecipeInfos.ts +++ b/src/api/getRecipeInfos.ts @@ -1,72 +1,24 @@ import AccessToken from '../types/AccessToken' -import { ApiError } from './ApiError' -import ExpiredTokenError from './ExpiredTokenError' -import { toImageView } from './types/ImageView' -import RecipeInfosView, { RawRecipeInfosView } from './types/RecipeInfosView' -import { addBearer } from './util' +import Refresh from '../types/Refresh' +import { getCallFactory } from './apiCallFactory' +import { toRecipeInfosView } from './types/RecipeInfosView' export interface GetRecipeInfosDeps { - abortSignal: AbortSignal accessToken: AccessToken | null pageNumber: number pageSize: number + refresh: Refresh + signal: AbortSignal } -const getRecipeInfos = async ({ - abortSignal, - accessToken, - pageNumber, - pageSize -}: GetRecipeInfosDeps): Promise => { - const headers = new Headers() - if (accessToken !== null) { - addBearer(headers, accessToken) - } - const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes?page=${pageNumber}&size=${pageSize}`, { - signal: abortSignal, - headers, - mode: 'cors' +const doGetRecipeInfos = getCallFactory(toRecipeInfosView) + +const getRecipeInfos = ({ accessToken, pageNumber, pageSize, refresh, signal }: GetRecipeInfosDeps) => + doGetRecipeInfos({ + accessToken, + endpoint: `/recipes?page=${pageNumber}&size=${pageSize}`, + refresh, + signal }) - if (response.ok) { - const { pageNumber, pageSize, content } = (await response.json()) as RawRecipeInfosView - return { - pageNumber, - pageSize, - content: content.map( - ({ - id, - updated: rawUpdated, - title, - preparationTime, - cookingTime, - totalTime, - ownerId, - owner, - isPublic, - starCount, - mainImage: rawMainImage, - slug - }) => ({ - id, - updated: new Date(rawUpdated), - title, - preparationTime, - cookingTime, - totalTime, - ownerId, - owner, - isPublic, - starCount, - mainImage: rawMainImage !== null ? toImageView(rawMainImage) : null, - slug - }) - ) - } - } else if (response.status === 401) { - throw new ExpiredTokenError() - } else { - throw new ApiError(response.status, response.statusText) - } -} export default getRecipeInfos diff --git a/src/api/types/RecipeInfoView.ts b/src/api/types/RecipeInfoView.ts index 020509a..1432f56 100644 --- a/src/api/types/RecipeInfoView.ts +++ b/src/api/types/RecipeInfoView.ts @@ -1,14 +1,14 @@ -import ImageView, { RawImageView } from './ImageView' +import ImageView, { RawImageView, toImageView } from './ImageView' import UserInfoView from './UserInfoView' export interface RawRecipeInfoView { id: number - updated: string + created: string + modified: string | null title: string preparationTime: number cookingTime: number totalTime: number - ownerId: number owner: UserInfoView isPublic: boolean starCount: number @@ -18,7 +18,8 @@ export interface RawRecipeInfoView { interface RecipeInfoView { id: number - updated: Date + created: Date + modified: Date | null title: string preparationTime: number cookingTime: number @@ -30,4 +31,32 @@ interface RecipeInfoView { slug: string } +export const toRecipeInfoView = ({ + id, + created: rawCreated, + modified: rawModified, + title, + preparationTime, + cookingTime, + totalTime, + owner, + isPublic, + starCount, + mainImage: rawMainImage, + slug +}: RawRecipeInfoView): RecipeInfoView => ({ + id, + created: new Date(rawCreated), + modified: rawModified !== null ? new Date(rawModified) : null, + title, + preparationTime, + cookingTime, + totalTime, + owner, + isPublic, + starCount, + mainImage: rawMainImage !== null ? toImageView(rawMainImage) : null, + slug +}) + export default RecipeInfoView diff --git a/src/api/types/RecipeInfosView.ts b/src/api/types/RecipeInfosView.ts index e9a0d8b..f7687f1 100644 --- a/src/api/types/RecipeInfosView.ts +++ b/src/api/types/RecipeInfosView.ts @@ -1,4 +1,4 @@ -import RecipeInfoView, { RawRecipeInfoView } from './RecipeInfoView' +import RecipeInfoView, { RawRecipeInfoView, toRecipeInfoView } from './RecipeInfoView' export interface RawRecipeInfosView { pageNumber: number @@ -12,4 +12,10 @@ interface RecipeInfosView { content: RecipeInfoView[] } +export const toRecipeInfosView = ({ pageNumber, pageSize, content }: RawRecipeInfosView): RecipeInfosView => ({ + pageNumber, + pageSize, + content: content.map(toRecipeInfoView) +}) + export default RecipeInfosView diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx index 51b3752..24d743f 100644 --- a/src/components/header/Header.tsx +++ b/src/components/header/Header.tsx @@ -1,5 +1,5 @@ import { useLocation, useNavigate } from '@tanstack/react-router' -import { useAuth } from '../../auth' +import { useAuth } from '../../AuthProvider' import classes from './header.module.css' export interface HeaderProps { diff --git a/src/main.tsx b/src/main.tsx index 9569bfb..8e7c1bd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,12 +1,16 @@ import { library } from '@fortawesome/fontawesome-svg-core' import { fas } from '@fortawesome/free-solid-svg-icons' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { Router, RouterProvider, createRouter } from '@tanstack/react-router' import React from 'react' import ReactDOM from 'react-dom/client' -import { AuthProvider, useAuth } from './auth' -import AuthAwareQueryClientProvider from './AuthAwareQueryClientProvider' +import { ApiError } from './api/ApiError' +import ExpiredTokenError from './api/ExpiredTokenError' +import { AuthProvider, useAuth } from './AuthProvider' import './main.css' import { routeTree } from './routeTree.gen' +import RefreshProvider from './RefreshProvider' // Font-Awesome: load icons library.add(fas) @@ -27,13 +31,41 @@ declare module '@tanstack/react-router' { } } +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry(failureCount, error) { + if (error instanceof ExpiredTokenError || (error instanceof ApiError && error.status === 404)) { + return false + } else { + return failureCount <= 3 + } + }, + retryDelay(failureCount, error) { + if (error instanceof ExpiredTokenError) { + return 0 + } else { + return failureCount * 1000 + } + } + } + } +}) + const InnerApp = () => { const auth = useAuth() return ( {children}} + InnerWrap={({ children }) => ( + + + {children} + + + + )} > ) } diff --git a/src/pages/edit-recipe/EditRecipe.tsx b/src/pages/edit-recipe/EditRecipe.tsx index ff32c12..1f3b757 100644 --- a/src/pages/edit-recipe/EditRecipe.tsx +++ b/src/pages/edit-recipe/EditRecipe.tsx @@ -5,7 +5,7 @@ import { ApiError } from '../../api/ApiError' import getRecipe from '../../api/getRecipe' import UpdateRecipeSpec, { fromFullRecipeView } from '../../api/types/UpdateRecipeSpec' import updateRecipe from '../../api/updateRecipe' -import { useAuth } from '../../auth' +import { useAuth } from '../../AuthProvider' import classes from './edit-recipe.module.css' interface ControlProps { diff --git a/src/pages/recipe/Recipe.tsx b/src/pages/recipe/Recipe.tsx index c5f176a..982fc6a 100644 --- a/src/pages/recipe/Recipe.tsx +++ b/src/pages/recipe/Recipe.tsx @@ -6,7 +6,7 @@ import getImage from '../../api/getImage' import getRecipe from '../../api/getRecipe' import removeStar from '../../api/removeStar' import GetRecipeView from '../../api/types/GetRecipeView' -import { useAuth } from '../../auth' +import { useAuth } from '../../AuthProvider' import RecipeVisibilityIcon from '../../components/recipe-visibility-icon/RecipeVisibilityIcon' import UserIconAndName from '../../components/user-icon-and-name/UserIconAndName' import classes from './recipe.module.css' diff --git a/src/pages/recipes/Recipes.tsx b/src/pages/recipes/Recipes.tsx index c12ef24..0687567 100644 --- a/src/pages/recipes/Recipes.tsx +++ b/src/pages/recipes/Recipes.tsx @@ -3,15 +3,17 @@ import { useState } from 'react' import { ApiError } from '../../api/ApiError' import getImage from '../../api/getImage' import getRecipeInfos from '../../api/getRecipeInfos' -import { useAuth } from '../../auth' +import { useAuth } from '../../AuthProvider' import RecipeCard from '../../components/recipe-card/RecipeCard' import classes from './recipes.module.css' +import { useRefresh } from '../../RefreshProvider' const Recipes = () => { const [pageNumber, setPageNumber] = useState(0) const [pageSize, setPageSize] = useState(20) const { accessToken } = useAuth() + const refresh = useRefresh() const queryClient = useQueryClient() const { data, isPending, error } = useQuery( @@ -19,10 +21,11 @@ const Recipes = () => { queryKey: ['recipeInfos'], queryFn: ({ signal }) => getRecipeInfos({ - abortSignal: signal, + accessToken, pageNumber, pageSize, - accessToken + refresh, + signal }) }, queryClient diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index fb328df..2fdb1fa 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,7 +1,7 @@ import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/router-devtools' import RouterContext from '../RouterContext' -import { useAuth } from '../auth' +import { useAuth } from '../AuthProvider' import Footer from '../components/footer/Footer' import Header from '../components/header/Header' import classes from './__root.module.css' diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 5f78dda..95db1f4 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -2,7 +2,7 @@ import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router' import { FormEvent, useState } from 'react' import { z } from 'zod' import login from '../api/login' -import { useAuth } from '../auth' +import { useAuth } from '../AuthProvider' const Login = () => { const { putToken } = useAuth() diff --git a/src/types/Refresh.ts b/src/types/Refresh.ts new file mode 100644 index 0000000..d8ba92a --- /dev/null +++ b/src/types/Refresh.ts @@ -0,0 +1,7 @@ +import AccessToken from './AccessToken' + +interface Refresh { + (): Promise +} + +export default Refresh