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