Files
movies-diary/spa/src/lib/api/client.ts

225 lines
5.5 KiB
TypeScript

import { clearAuth, getAuth, getToken, setAuth } from "@/lib/auth"
export const API_URL = import.meta.env.VITE_API_URL ?? ""
export function posterUrl(path: string | undefined | null): string | undefined {
if (!path) return undefined
if (path.startsWith("http://") || path.startsWith("https://")) return path
const clean = path.startsWith("/") ? path.slice(1) : path
return `${API_URL}/images/${clean}`
}
export function tmdbProfileUrl(path: string | undefined | null): string | undefined {
if (!path) return undefined
return `https://image.tmdb.org/t/p/w185${path}`
}
export class ApiError extends Error {
status: number
body: string
constructor(status: number, body: string) {
super(`API ${status}: ${body}`)
this.status = status
this.body = body
}
}
function authHeaders(): HeadersInit {
const token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
function buildUrl(
path: string,
params?: Record<string, unknown>,
): string {
const base = `${API_URL}/api/v1${path}`
if (!params) return base
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v != null) sp.set(k, String(v))
}
const qs = sp.toString()
return qs ? `${base}?${qs}` : base
}
let refreshPromise: Promise<boolean> | null = null
async function tryRefresh(): Promise<boolean> {
const auth = getAuth()
if (!auth?.refresh_token) return false
try {
const res = await fetch(buildUrl("/auth/refresh"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: auth.refresh_token }),
})
if (!res.ok) return false
const data = await res.json()
setAuth({
...auth,
token: data.token,
refresh_token: data.refresh_token,
expires_at: data.expires_at,
})
return true
} catch {
return false
}
}
async function isInvalidCredentials(res: Response): Promise<boolean> {
try {
const body = await res.clone().json()
return body?.code === "INVALID_CREDENTIALS"
} catch {
return false
}
}
async function request<T = void>(
url: string,
options?: RequestInit,
): Promise<T> {
const res = await fetch(url, {
...options,
headers: {
...authHeaders(),
...options?.headers,
},
})
if (!res.ok) {
if (res.status === 401) {
// A failed re-auth check (wrong password) is not an expired session:
// no refresh, no retry, no logout.
if (await isInvalidCredentials(res)) {
throw new ApiError(res.status, await res.text())
}
if (url.includes("/auth/refresh") || url.includes("/auth/login")) {
clearAuth()
window.location.href = "/app/login"
throw new ApiError(res.status, await res.text())
}
if (!refreshPromise) {
refreshPromise = tryRefresh().finally(() => {
refreshPromise = null
})
}
const ok = await refreshPromise
if (!ok) {
clearAuth()
window.location.href = "/app/login"
throw new ApiError(res.status, await res.text())
}
const retryRes = await fetch(url, {
...options,
headers: {
...authHeaders(),
...options?.headers,
},
})
if (!retryRes.ok) {
throw new ApiError(retryRes.status, await retryRes.text())
}
const retryText = await retryRes.text()
return retryText ? JSON.parse(retryText) : (undefined as T)
}
throw new ApiError(res.status, await res.text())
}
const text = await res.text()
return text ? JSON.parse(text) : (undefined as T)
}
export async function get<T>(
path: string,
params?: Record<string, unknown>,
): Promise<T> {
return request<T>(buildUrl(path, params))
}
export async function post<T = void>(
path: string,
body?: unknown,
): Promise<T> {
return request<T>(buildUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
})
}
export async function put<T = void>(
path: string,
body?: unknown,
): Promise<T> {
return request<T>(buildUrl(path), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
})
}
export async function putForm<T = void>(
path: string,
form: FormData,
): Promise<T> {
return request<T>(buildUrl(path), {
method: "PUT",
body: form,
})
}
export async function patch<T = void>(
path: string,
body?: unknown,
): Promise<T> {
return request<T>(buildUrl(path), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
})
}
export async function del<T = void>(path: string): Promise<T> {
return request<T>(buildUrl(path), { method: "DELETE" })
}
export async function delWithBody<T = void>(
path: string,
body: unknown,
): Promise<T> {
return request<T>(buildUrl(path), {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
}
export async function upload<T>(
path: string,
file: File,
): Promise<T> {
const form = new FormData()
form.append("file", file)
return request<T>(buildUrl(path), {
method: "POST",
body: form,
})
}
export async function uploadWithFields<T>(
path: string,
file: File,
fields: Record<string, string>,
): Promise<T> {
const form = new FormData()
form.append("file", file)
for (const [k, v] of Object.entries(fields)) form.append(k, v)
return request<T>(buildUrl(path), {
method: "POST",
body: form,
})
}