Updated all api calls to use new apiCallFactory.

This commit is contained in:
Jesse Brault 2024-08-22 08:00:10 -05:00
parent c54d3832a3
commit a3376a8cc1
10 changed files with 173 additions and 146 deletions

View File

@ -1,27 +1,21 @@
import AccessToken from '../types/AccessToken' import AccessToken from '../types/AccessToken'
import { ApiError } from './ApiError' import Refresh from '../types/Refresh'
import ExpiredTokenError from './ExpiredTokenError' import apiCallFactory from './apiCallFactory'
import { addBearer } from './util'
export interface AddStarDeps { export interface AddStarDeps {
accessToken: AccessToken accessToken: AccessToken
refresh: Refresh
username: string username: string
slug: string slug: string
} }
const addStar = async ({ slug, accessToken, username }: AddStarDeps): Promise<void> => { const doAddStar = apiCallFactory<void>('POST')
const headers = new Headers()
addBearer(headers, accessToken) const addStar = ({ accessToken, refresh, username, slug }: AddStarDeps) =>
const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}/star`, { doAddStar({
headers, accessToken,
method: 'POST', endpoint: `/recipes/${username}/${slug}/star`,
mode: 'cors' refresh
}) })
if (response.status === 401) {
throw new ExpiredTokenError()
} else if (!response.ok) {
throw new ApiError(response.status, response.statusText)
}
}
export default addStar export default addStar

View File

@ -2,38 +2,87 @@ import AccessToken from '../types/AccessToken'
import Refresh from '../types/Refresh' import Refresh from '../types/Refresh'
import { ApiError } from './ApiError' 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' export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'
const getApiCallFactory = export type ApiCallFactoryDeps<T> = WithHandleJson<T> | WithHandleResponse<T>
(method: Method) =>
<T>(handleBody?: (raw: any) => T) => export interface WithHandleJson<T> {
async ({ accessToken, endpoint, refresh, signal, body }: ApiCallDeps): Promise<T> => { handleJson: (raw: any) => T
}
export interface WithHandleResponse<T> {
handleResponse: (response: Response) => Promise<T> | T
}
export type ApiCallDeps = {
accessToken: AccessToken | null
refresh: Refresh
signal?: AbortSignal
body?: any
} & (WithEndpoint | WithUrl)
export interface WithEndpoint {
endpoint: string
query?: string
}
export interface WithUrl {
url: string
}
export type ApiCall<T> = (deps: ApiCallDeps) => Promise<T>
export interface ApiCallFactory<T> {
(method: Method): ApiCall<void>
(method: Method, handleBody: (raw: any) => T): ApiCall<T>
(method: Method, deps: ApiCallFactoryDeps<T>): ApiCall<T>
}
const handleResult = async <T>(
handleBodyOrDeps: ((raw: any) => T) | ApiCallFactoryDeps<T>,
response: Response
): Promise<T> => {
if (typeof handleBodyOrDeps === 'function') {
return handleBodyOrDeps(await response.json())
} else {
const deps = handleBodyOrDeps
if ('handleResponse' in deps) {
return deps.handleResponse(response)
} else {
return deps.handleJson(await response.json())
}
}
}
const apiCallFactory = <T>(
method: Method,
handleBodyOrDeps?: ((raw: any) => T) | ApiCallFactoryDeps<T>
): ApiCall<typeof handleBodyOrDeps extends undefined ? void : T> => {
return (async (deps: ApiCallDeps): Promise<void | T> => {
const { accessToken, refresh, signal } = deps
const headers = new Headers() const headers = new Headers()
if (accessToken) { if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken.token}`) headers.set('Authorization', `Bearer ${accessToken.token}`)
} }
if (body) { if (deps.body) {
headers.set('Content-type', 'application/json') headers.set('Content-type', 'application/json')
} }
const url = import.meta.env.VITE_MME_API_URL + endpoint const url =
'url' in deps
? deps.url
: deps.query
? import.meta.env.VITE_MME_API_URL + deps.endpoint + '?' + deps.query
: import.meta.env.VITE_MME_API_URL + deps.endpoint
const body = deps.body ? JSON.stringify(deps.body) : undefined
const response = await fetch(url, { const response = await fetch(url, {
body: body ? JSON.stringify(body) : undefined, body,
signal, signal,
headers, headers,
mode: 'cors', mode: 'cors',
method method
}) })
if (response.ok && handleBody) { if (response.ok && handleBodyOrDeps) {
return handleBody(await response.json()) return handleResult(handleBodyOrDeps, response)
} else if (response.status === 401) { } else if (response.status === 401) {
const newToken = await refresh() const newToken = await refresh()
if (newToken === null) { if (newToken === null) {
@ -41,22 +90,21 @@ const getApiCallFactory =
} }
headers.set('Authorization', `Bearer ${newToken.token}`) headers.set('Authorization', `Bearer ${newToken.token}`)
const retry = await fetch(url, { const retry = await fetch(url, {
body,
signal, signal,
headers, headers,
mode: 'cors', mode: 'cors',
method method
}) })
if (retry.ok && handleBody) { if (retry.ok && handleBodyOrDeps) {
return handleBody(await retry.json()) return handleResult(handleBodyOrDeps, retry)
} else { } else if (!retry.ok) {
throw new ApiError(retry.status, retry.statusText) throw new ApiError(retry.status, retry.statusText)
} }
} else { } else if (!response.ok) {
throw new ApiError(response.status, response.statusText) throw new ApiError(response.status, response.statusText)
} }
}) as ApiCall<T>
} }
export const getCallFactory = getApiCallFactory('GET') export default apiCallFactory
export const postCallFactory = getApiCallFactory('POST')
export const putCallFactory = getApiCallFactory('PUT')
export const deleteCallFactory = getApiCallFactory('DELETE')

View File

@ -1,31 +1,24 @@
import AccessToken from '../types/AccessToken' import AccessToken from '../types/AccessToken'
import { ApiError } from './ApiError' import Refresh from '../types/Refresh'
import ExpiredTokenError from './ExpiredTokenError' import apiCallFactory from './apiCallFactory'
import { addBearer } from './util'
export interface GetImageDeps { export interface GetImageDeps {
accessToken: AccessToken | null accessToken: AccessToken | null
refresh: Refresh
signal: AbortSignal signal: AbortSignal
url: string url: string
} }
const getImage = async ({ accessToken, signal, url }: GetImageDeps): Promise<string> => { const doGetImage = apiCallFactory('GET', {
const headers = new Headers() handleResponse: async res => URL.createObjectURL(await res.blob())
if (accessToken !== null) { })
addBearer(headers, accessToken)
} const getImage = async ({ accessToken, refresh, signal, url }: GetImageDeps) =>
const response = await fetch(url, { doGetImage({
headers, accessToken,
mode: 'cors', refresh,
signal signal,
url
}) })
if (response.ok) {
return URL.createObjectURL(await response.blob())
} else if (response.status === 401) {
throw new ExpiredTokenError()
} else {
throw new ApiError(response.status, response.statusText)
}
}
export default getImage export default getImage

View File

@ -1,20 +1,18 @@
import AccessToken from '../types/AccessToken' import AccessToken from '../types/AccessToken'
import { ApiError } from './ApiError' import Refresh from '../types/Refresh'
import ExpiredTokenError from './ExpiredTokenError' import apiCallFactory from './apiCallFactory'
import GetRecipeView, { import GetRecipeView, {
GetRecipeViewWithRawText, GetRecipeViewWithRawText,
RawGetRecipeView,
RawGetRecipeViewWithRawText,
toGetRecipeView, toGetRecipeView,
toGetRecipeViewWithRawText toGetRecipeViewWithRawText
} from './types/GetRecipeView' } from './types/GetRecipeView'
import { addBearer } from './util'
export interface GetRecipeCommonDeps { export interface GetRecipeCommonDeps {
accessToken: AccessToken | null accessToken: AccessToken | null
username: string refresh: Refresh
slug: string slug: string
abortSignal: AbortSignal signal: AbortSignal
username: string
} }
export interface GetRecipeDeps extends GetRecipeCommonDeps { export interface GetRecipeDeps extends GetRecipeCommonDeps {
@ -30,33 +28,33 @@ export interface GetRecipe {
(deps: GetRecipeDepsIncludeRawText): Promise<GetRecipeViewWithRawText> (deps: GetRecipeDepsIncludeRawText): Promise<GetRecipeViewWithRawText>
} }
const doGetRecipe = apiCallFactory('GET', toGetRecipeView)
const doGetRecipeIncludeRawText = apiCallFactory('GET', toGetRecipeViewWithRawText)
const getRecipe = (async ({ const getRecipe = (async ({
accessToken, accessToken,
username, includeRawText,
refresh,
slug, slug,
abortSignal, signal,
includeRawText username
}: GetRecipeDeps | GetRecipeDepsIncludeRawText): Promise<GetRecipeView | GetRecipeViewWithRawText> => { }: GetRecipeDeps | GetRecipeDepsIncludeRawText): Promise<GetRecipeView | GetRecipeViewWithRawText> => {
const headers = new Headers() const endpoint = `/recipes/${username}/${slug}`
if (accessToken !== null) {
addBearer(headers, accessToken)
}
const query = includeRawText ? '?includeRawText=true' : ''
const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}${query}`, {
signal: abortSignal,
headers,
mode: 'cors'
})
if (response.ok) {
if (includeRawText) { if (includeRawText) {
return toGetRecipeViewWithRawText((await response.json()) as RawGetRecipeViewWithRawText) return doGetRecipeIncludeRawText({
accessToken,
endpoint,
query: 'includeRawText=true',
refresh,
signal
})
} else { } else {
return toGetRecipeView((await response.json()) as RawGetRecipeView) return doGetRecipe({
} accessToken,
} else if (response.status === 401) { endpoint,
throw new ExpiredTokenError() refresh,
} else { signal
throw new ApiError(response.status, response.statusText) })
} }
}) as GetRecipe }) as GetRecipe

View File

@ -1,6 +1,6 @@
import AccessToken from '../types/AccessToken' import AccessToken from '../types/AccessToken'
import Refresh from '../types/Refresh' import Refresh from '../types/Refresh'
import { getCallFactory } from './apiCallFactory' import apiCallFactory from './apiCallFactory'
import { toRecipeInfosView } from './types/RecipeInfosView' import { toRecipeInfosView } from './types/RecipeInfosView'
export interface GetRecipeInfosDeps { export interface GetRecipeInfosDeps {
@ -11,7 +11,7 @@ export interface GetRecipeInfosDeps {
signal: AbortSignal signal: AbortSignal
} }
const doGetRecipeInfos = getCallFactory(toRecipeInfosView) const doGetRecipeInfos = apiCallFactory('GET', toRecipeInfosView)
const getRecipeInfos = ({ accessToken, pageNumber, pageSize, refresh, signal }: GetRecipeInfosDeps) => const getRecipeInfos = ({ accessToken, pageNumber, pageSize, refresh, signal }: GetRecipeInfosDeps) =>
doGetRecipeInfos({ doGetRecipeInfos({

View File

@ -1,27 +1,21 @@
import AccessToken from '../types/AccessToken' import AccessToken from '../types/AccessToken'
import { ApiError } from './ApiError' import Refresh from '../types/Refresh'
import ExpiredTokenError from './ExpiredTokenError' import apiCallFactory from './apiCallFactory'
import { addBearer } from './util'
export interface RemoveStarDeps { export interface RemoveStarDeps {
accessToken: AccessToken accessToken: AccessToken
refresh: Refresh
username: string username: string
slug: string slug: string
} }
const removeStar = async ({ accessToken, username, slug }: RemoveStarDeps) => { const doRemoveStar = apiCallFactory<void>('DELETE')
const headers = new Headers()
addBearer(headers, accessToken) const removeStar = ({ accessToken, refresh, username, slug }: RemoveStarDeps) =>
const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}/star`, { doRemoveStar({
headers, accessToken,
method: 'DELETE', endpoint: `/recipes/${username}/${slug}/star`,
mode: 'cors' refresh
}) })
if (response.status === 401) {
throw new ExpiredTokenError()
} else if (!response.ok) {
throw new ApiError(response.status, response.statusText)
}
}
export default removeStar export default removeStar

View File

@ -1,43 +1,31 @@
import AccessToken from '../types/AccessToken' import AccessToken from '../types/AccessToken'
import { ApiError } from './ApiError' import Refresh from '../types/Refresh'
import ExpiredTokenError from './ExpiredTokenError' import apiCallFactory from './apiCallFactory'
import { import { GetRecipeViewWithRawText, toGetRecipeViewWithRawText } from './types/GetRecipeView'
GetRecipeViewWithRawText,
RawGetRecipeViewWithRawText,
toGetRecipeViewWithRawText
} from './types/GetRecipeView'
import UpdateRecipeSpec from './types/UpdateRecipeSpec' import UpdateRecipeSpec from './types/UpdateRecipeSpec'
import { addBearer } from './util'
export interface UpdateRecipeDeps { export interface UpdateRecipeDeps {
spec: UpdateRecipeSpec spec: UpdateRecipeSpec
accessToken: AccessToken accessToken: AccessToken
refresh: Refresh
username: string username: string
slug: string slug: string
} }
const updateRecipe = async ({ const doUpdateRecipe = apiCallFactory('POST', toGetRecipeViewWithRawText)
const updateRecipe = ({
spec, spec,
accessToken, accessToken,
refresh,
username, username,
slug slug
}: UpdateRecipeDeps): Promise<GetRecipeViewWithRawText> => { }: UpdateRecipeDeps): Promise<GetRecipeViewWithRawText> =>
const headers = new Headers() doUpdateRecipe({
addBearer(headers, accessToken) accessToken,
headers.set('Content-type', 'application/json') body: spec,
const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}`, { endpoint: `/recipes/${username}/${slug}`,
headers, refresh
method: 'POST',
mode: 'cors',
body: JSON.stringify(spec)
}) })
if (response.ok) {
return toGetRecipeViewWithRawText((await response.json()) as RawGetRecipeViewWithRawText)
} else if (response.status === 401) {
throw new ExpiredTokenError()
} else {
throw new ApiError(response.status, response.statusText)
}
}
export default updateRecipe export default updateRecipe

View File

@ -7,6 +7,7 @@ import UpdateRecipeSpec, { fromFullRecipeView } from '../../api/types/UpdateReci
import updateRecipe from '../../api/updateRecipe' import updateRecipe from '../../api/updateRecipe'
import { useAuth } from '../../AuthProvider' import { useAuth } from '../../AuthProvider'
import classes from './edit-recipe.module.css' import classes from './edit-recipe.module.css'
import { useRefresh } from '../../RefreshProvider'
interface ControlProps { interface ControlProps {
id: string id: string
@ -88,6 +89,7 @@ export interface EditRecipeProps {
const EditRecipe = ({ username, slug }: EditRecipeProps) => { const EditRecipe = ({ username, slug }: EditRecipeProps) => {
const { accessToken } = useAuth() const { accessToken } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const refresh = useRefresh()
// useEffect(() => { // useEffect(() => {
// if (auth.token === null) { // if (auth.token === null) {
@ -106,10 +108,11 @@ const EditRecipe = ({ username, slug }: EditRecipeProps) => {
queryFn: ({ signal }) => queryFn: ({ signal }) =>
getRecipe({ getRecipe({
accessToken, accessToken,
username, includeRawText: true,
refresh,
slug, slug,
abortSignal: signal, signal,
includeRawText: true username
}) })
}, },
queryClient queryClient
@ -144,6 +147,7 @@ const EditRecipe = ({ username, slug }: EditRecipeProps) => {
return updateRecipe({ return updateRecipe({
spec, spec,
accessToken, accessToken,
refresh,
username, username,
slug slug
}) })

View File

@ -1,5 +1,6 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { QueryObserverSuccessResult, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { QueryObserverSuccessResult, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import addStar from '../../api/addStar' import addStar from '../../api/addStar'
import { ApiError } from '../../api/ApiError' import { ApiError } from '../../api/ApiError'
import getImage from '../../api/getImage' import getImage from '../../api/getImage'
@ -9,8 +10,8 @@ import GetRecipeView from '../../api/types/GetRecipeView'
import { useAuth } from '../../AuthProvider' import { useAuth } from '../../AuthProvider'
import RecipeVisibilityIcon from '../../components/recipe-visibility-icon/RecipeVisibilityIcon' import RecipeVisibilityIcon from '../../components/recipe-visibility-icon/RecipeVisibilityIcon'
import UserIconAndName from '../../components/user-icon-and-name/UserIconAndName' import UserIconAndName from '../../components/user-icon-and-name/UserIconAndName'
import { useRefresh } from '../../RefreshProvider'
import classes from './recipe.module.css' import classes from './recipe.module.css'
import { useNavigate } from '@tanstack/react-router'
interface EditButtonProps { interface EditButtonProps {
username: string username: string
@ -53,14 +54,16 @@ interface RecipeStarButtonProps {
const RecipeStarButton = ({ username, slug, isStarred, starCount }: RecipeStarButtonProps) => { const RecipeStarButton = ({ username, slug, isStarred, starCount }: RecipeStarButtonProps) => {
const { accessToken } = useAuth() const { accessToken } = useAuth()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const refresh = useRefresh()
const addStarMutation = useMutation({ const addStarMutation = useMutation({
mutationFn: () => { mutationFn: () => {
if (accessToken !== null) { if (accessToken !== null) {
return addStar({ return addStar({
accessToken, accessToken,
slug, refresh,
username username,
slug
}) })
} else { } else {
return Promise.resolve() return Promise.resolve()
@ -76,6 +79,7 @@ const RecipeStarButton = ({ username, slug, isStarred, starCount }: RecipeStarBu
if (accessToken !== null) { if (accessToken !== null) {
return removeStar({ return removeStar({
accessToken, accessToken,
refresh,
slug, slug,
username username
}) })
@ -113,16 +117,18 @@ export interface RecipeProps {
const Recipe = ({ username, slug }: RecipeProps) => { const Recipe = ({ username, slug }: RecipeProps) => {
const { accessToken } = useAuth() const { accessToken } = useAuth()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const refresh = useRefresh()
const recipeQuery = useQuery( const recipeQuery = useQuery(
{ {
queryKey: ['recipes', username, slug], queryKey: ['recipes', username, slug],
queryFn: ({ signal: abortSignal }) => queryFn: ({ signal }) =>
getRecipe({ getRecipe({
abortSignal,
accessToken, accessToken,
username, refresh,
slug signal,
slug,
username
}) })
}, },
queryClient queryClient
@ -140,6 +146,7 @@ const Recipe = ({ username, slug }: RecipeProps) => {
getImage({ getImage({
accessToken, accessToken,
signal, signal,
refresh,
url: recipeQuery.data!.recipe.mainImage!.url url: recipeQuery.data!.recipe.mainImage!.url
}) })
}, },

View File

@ -46,6 +46,7 @@ const Recipes = () => {
// any needed in the params // any needed in the params
const imgUrl = await getImage({ const imgUrl = await getImage({
accessToken, accessToken,
refresh,
signal, signal,
url: recipeInfoView.mainImage!.url url: recipeInfoView.mainImage!.url
}) })