Major refactor of auth, refresh, and api calls.

This commit is contained in:
Jesse Brault 2024-08-21 10:20:12 -05:00
parent c099275b31
commit c54d3832a3
16 changed files with 244 additions and 173 deletions

View File

@ -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 (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools position="right" buttonPosition="top-right" />
</QueryClientProvider>
)
}
export default AuthAwareQueryClientProvider

View File

@ -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<AuthContextType | null>(null)
export const AuthProvider = ({ children }: React.PropsWithChildren) => {
export const AuthProvider = ({ children }: PropsWithChildren) => {
const [state, dispatch] = useReducer(authReducer, initialState)
return (

73
src/RefreshProvider.tsx Normal file
View File

@ -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<RefreshContextType | null>(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 (
<RefreshContext.Provider
value={{
refresh: doRefresh
}}
>
{children}
</RefreshContext.Provider>
)
}
export default RefreshProvider

View File

@ -1,4 +1,4 @@
import { AuthContextType } from './auth'
import { AuthContextType } from './AuthProvider'
export default interface RouterContext {
auth: AuthContextType

62
src/api/apiCallFactory.ts Normal file
View File

@ -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) =>
<T>(handleBody?: (raw: any) => T) =>
async ({ accessToken, endpoint, refresh, signal, body }: ApiCallDeps): Promise<T> => {
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')

View File

@ -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,
const doGetRecipeInfos = getCallFactory(toRecipeInfosView)
const getRecipeInfos = ({ accessToken, pageNumber, pageSize, refresh, signal }: GetRecipeInfosDeps) =>
doGetRecipeInfos({
accessToken,
pageNumber,
pageSize
}: GetRecipeInfosDeps): Promise<RecipeInfosView> => {
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'
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

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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 (
<RouterProvider
router={router}
context={{ auth }}
InnerWrap={({ children }) => <AuthAwareQueryClientProvider>{children}</AuthAwareQueryClientProvider>}
InnerWrap={({ children }) => (
<QueryClientProvider client={queryClient}>
<RefreshProvider>
{children}
<ReactQueryDevtools position="right" buttonPosition="top-right" />
</RefreshProvider>
</QueryClientProvider>
)}
></RouterProvider>
)
}

View File

@ -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 {

View File

@ -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'

View File

@ -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

View File

@ -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'

View File

@ -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()

7
src/types/Refresh.ts Normal file
View File

@ -0,0 +1,7 @@
import AccessToken from './AccessToken'
interface Refresh {
(): Promise<AccessToken | null>
}
export default Refresh