From 47db7abd6abb604025024baec127c2138eb8c335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20T=C3=A4kker?= Date: Tue, 29 Sep 2026 13:33:48 +0200 Subject: [PATCH] Switched frontend API calls to Nuxt --- app/components/Album/Slider.vue | 4 +- app/composables/useApiFetch.ts | 15 ++--- app/composables/useAuth.ts | 27 +++++++- app/pages/admin/songs.vue | 4 +- app/utils/cfetch.ts | 108 +++++++++++++++++--------------- 5 files changed, 89 insertions(+), 69 deletions(-) diff --git a/app/components/Album/Slider.vue b/app/components/Album/Slider.vue index 75cf7e7..5d83b17 100644 --- a/app/components/Album/Slider.vue +++ b/app/components/Album/Slider.vue @@ -3,11 +3,11 @@ const { data: genresData } = await useApiFetch("/genres/random") const data = await Promise.allSettled( (genresData.value ?? []).map(async (genre: Genre) => { - const res = await cfetch(`/genres/albums/${genre._id}`) + const albums = await cfetch(`/genres/albums/${genre._id}`) return { genre, - albums: res.data, + albums, } }), ).then((results) => diff --git a/app/composables/useApiFetch.ts b/app/composables/useApiFetch.ts index a19f902..b438f04 100644 --- a/app/composables/useApiFetch.ts +++ b/app/composables/useApiFetch.ts @@ -6,14 +6,11 @@ export function useApiFetch( options?: UseFetchOptions, authorize: boolean = true, ): ReturnType> { - const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND - - if (!baseUrl) throw new Error("BACKEND URL not set") - const { sessionToken, refreshSessionToken } = useAuth() const defaults: UseFetchOptions = { - baseURL: baseUrl, + // The migrated API lives in this Nuxt app under server/api. + baseURL: "/api", retry: 1, retryDelay: 0, @@ -29,11 +26,9 @@ export function useApiFetch( if (!newToken) return - options.headers = { - ...(options.headers as unknown as Record), - // @ts-ignore - Authorization: `Bearer ${newToken}`, - } + const headers = new Headers(options.headers as HeadersInit) + headers.set("Authorization", `Bearer ${newToken}`) + options.headers = headers } }, } diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index fc102bf..7162c96 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -1,3 +1,5 @@ +import { appendResponseHeader } from "h3" + type SessionResponse = { sessionToken: string expireAt: string @@ -50,9 +52,28 @@ export const useAuth = () => { const refreshSessionToken = async (): Promise => { if (refreshPromise) return refreshPromise - refreshPromise = $fetch("/api/auth/session", { - method: "POST", - }) + refreshPromise = (async () => { + const response = await $fetch.raw( + "/api/auth/session", + { + method: "POST", + headers: import.meta.server + ? useRequestHeaders(["cookie", "authorization"]) + : undefined, + }, + ) + + if (import.meta.server) { + const event = useRequestEvent() + if (event) { + for (const cookie of response.headers.getSetCookie()) { + appendResponseHeader(event, "set-cookie", cookie) + } + } + } + + return response._data! + })() .then((response) => { sessionToken.value = response.sessionToken return response.sessionToken diff --git a/app/pages/admin/songs.vue b/app/pages/admin/songs.vue index d91cd98..8741e23 100644 --- a/app/pages/admin/songs.vue +++ b/app/pages/admin/songs.vue @@ -147,7 +147,7 @@ const save = async (value: any) => { return false } - const albumResponse = await cfetch("/admin/album", { + const albumResponse = await cfetch<{ _id: string }>("/admin/album", { method: "POST", data: { name: `${value.name} - Single`, @@ -156,7 +156,7 @@ const save = async (value: any) => { file: value.cover, }, }) - value.album = albumResponse.data._id + value.album = albumResponse._id } else if (!value.album) { toast.show( "Choose an album or create a single album", diff --git a/app/utils/cfetch.ts b/app/utils/cfetch.ts index 0ccdcc8..19cd085 100644 --- a/app/utils/cfetch.ts +++ b/app/utils/cfetch.ts @@ -1,78 +1,82 @@ -import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios" -import defu from "defu" +import type { FetchOptions } from "ofetch" -const hasFile = (value: any): boolean => { +type RequestConfig = Omit & { + data?: unknown + params?: Record + forceFormData?: boolean +} + +const hasFile = (value: unknown): boolean => { if (!value || typeof value !== "object") return false + const isFile = (item: unknown) => + typeof File !== "undefined" && item instanceof File + const isBlob = (item: unknown) => + typeof Blob !== "undefined" && item instanceof Blob + return Object.values(value).some( - (v) => - v instanceof File || - v instanceof Blob || - (Array.isArray(v) && v.some((i) => i instanceof File)), + (item) => + isFile(item) || + isBlob(item) || + (Array.isArray(item) && item.some((entry) => isFile(entry) || isBlob(entry))), ) } -type RequestConfig = { - forceFormData?: boolean -} & AxiosRequestConfig +const toApiUrl = (url: string) => { + if (url === "/api" || url.startsWith("/api/")) return url + return `/api${url.startsWith("/") ? url : `/${url}`}` +} -export const cfetch = async ( +export const cfetch = async ( url: string, options: RequestConfig = {}, - authorize: boolean = true, -): Promise => { - const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND + authorize = true, +): Promise => { + const { data, params, forceFormData, ...fetchOptions } = options + const headers = new Headers(fetchOptions.headers as HeadersInit | undefined) + const requestOptions: FetchOptions = { + ...fetchOptions, + query: params ?? (fetchOptions as FetchOptions).query, + body: data ?? (fetchOptions as FetchOptions).body, + headers, + } - if (!baseUrl) throw new Error("BACKEND URL not set") + if ( + requestOptions.body && + (typeof requestOptions.body === "object" || forceFormData) && + (hasFile(requestOptions.body) || forceFormData) + ) { + const form = new FormData() - const defaults: AxiosRequestConfig = { - baseURL: baseUrl, + for (const [key, value] of Object.entries(requestOptions.body as Record)) { + if (Array.isArray(value)) { + value.forEach((entry) => form.append(key, entry as string | Blob)) + } else if (value !== undefined && value !== null) { + form.append(key, value as string | Blob) + } + } + + requestOptions.body = form + headers.delete("Content-Type") } if (authorize) { const { sessionToken, refreshSessionToken } = useAuth() - let token = sessionToken.value - if (token) defaults.headers = { Authorization: `Bearer ${token}` } - else - defaults.headers = { - Authorization: `Bearer ${await refreshSessionToken()}`, - } + if (!token) token = await refreshSessionToken() + if (token) headers.set("Authorization", `Bearer ${token}`) } - const config: RequestConfig = defu(options, defaults) - - if (config.data && (hasFile(config.data) || config.forceFormData)) { - const form = new FormData() - - for (const [key, val] of Object.entries(config.data)) { - if (Array.isArray(val)) { - val.forEach((v) => form.append(key, v)) // key as-is, no [] - } else if (val !== undefined && val !== null) { - form.append(key, val as any) - } - } - - config.data = form - if (!config.headers) config.headers = {} - delete config.headers["Content-Type"] - } - - let response try { - response = await axios(url, config) + return (await $fetch(toApiUrl(url), requestOptions as any)) as DataT } catch (error: any) { - if (error.response?.status == 401) { - const { sessionToken, refreshSessionToken } = useAuth() + if (!authorize || error?.response?.status !== 401) throw error - if (!config.headers) config.headers = {} - config.headers.Authorization = `Bearer ${await refreshSessionToken()}` + const token = await useAuth().refreshSessionToken() + if (!token) throw error - response = await axios(url, config) - } else throw error + headers.set("Authorization", `Bearer ${token}`) + return (await $fetch(toApiUrl(url), requestOptions as any)) as DataT } - - if (!response) throw new Error("Something went wrong. Please try again") - return response }