Kyoo/front/src/query/query.tsx
2025-11-03 18:37:18 +00:00

307 lines
7.6 KiB
TypeScript

import {
dehydrate,
QueryClient,
useInfiniteQuery,
useQuery,
useQueryClient,
useMutation as useRQMutation,
} from "@tanstack/react-query";
import { useContext } from "react";
import { Platform } from "react-native";
import type { z } from "zod/v4";
import { type KyooError, type Page, Paged } from "~/models";
import { AccountContext } from "~/providers/account-context";
import { setServerData } from "~/utils";
const ssrApiUrl = process.env.KYOO_URL ?? "http://api:3567/api";
const cleanSlash = (str: string | null) => {
if (str === null) return null;
return str.replace(/\/$/g, "");
};
export const queryFn = async <Parser extends z.ZodTypeAny>(context: {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
url: string;
body?: object;
formData?: FormData;
plainText?: boolean;
authToken: string | null;
parser: Parser | null;
signal?: AbortSignal;
}): Promise<z.infer<Parser>> => {
if (
Platform.OS === "web" &&
typeof window === "undefined" &&
context.url.startsWith("/")
)
context.url = `${ssrApiUrl}${context.url}`;
let resp: Response;
try {
resp = await fetch(context.url, {
method: context.method,
body:
"body" in context && context.body
? JSON.stringify(context.body)
: "formData" in context && context.formData
? context.formData
: undefined,
headers: {
...(context.authToken
? { Authorization: `Bearer ${context.authToken}` }
: {}),
...("body" in context ? { "Content-Type": "application/json" } : {}),
},
signal: context.signal,
});
} catch (e) {
if (typeof e === "object" && e && "name" in e && e.name === "AbortError")
throw { message: "Aborted", status: "aborted" } as KyooError;
console.log("Fetch error", e, context.url);
throw {
message: "Could not reach Kyoo's server.",
status: "aborted",
} as KyooError;
}
if (resp.status === 404) {
throw { message: "Resource not found.", status: 404 } as KyooError;
}
if (!resp.ok) {
const error = await resp.text();
let data: Record<string, any>;
try {
data = JSON.parse(error);
} catch (e) {
data = { message: error } as KyooError;
}
data.status = resp.status;
console.log(
`Invalid response (${context.method ?? "GET"} ${context.url}):`,
data,
resp.status,
);
throw data as KyooError;
}
if (resp.status === 204) return null!;
if (context.plainText) return (await resp.text()) as any;
let data: Record<string, any>;
try {
data = await resp.json();
} catch (e) {
console.error("Invalid json from kyoo", e);
throw {
message: "Invalid response from kyoo",
status: "json",
} as KyooError;
}
if (!context.parser) return data as any;
const parsed = await context.parser.safeParseAsync(data);
if (!parsed.success) {
console.log(
"Url: ",
context.url,
" Response: ",
resp.status,
" Parse error: ",
parsed.error,
);
console.log(parsed.error.issues);
throw {
status: "parse",
message:
"Invalid response from kyoo. Possible version mismatch between the server and the application.",
} as KyooError;
}
return parsed.data;
};
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
// 5min
staleTime: 300_000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
},
});
export type QueryIdentifier<T = unknown> = {
parser: z.ZodType<T> | null;
path: (string | undefined)[];
params?: {
[query: string]: boolean | number | string | string[] | undefined;
};
infinite?: boolean;
placeholderData?: T | (() => T);
enabled?: boolean;
options?: Partial<Parameters<typeof queryFn>[0]> & {
apiUrl?: string;
};
};
const toQueryKey = (query: {
apiUrl: string;
path: (string | undefined)[];
params?: {
[query: string]: boolean | number | string | string[] | undefined;
};
}) => {
return [
cleanSlash(query.apiUrl),
...query.path,
query.params
? `?${Object.entries(query.params)
.filter(([_, v]) => v !== undefined)
.map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`)
.join("&")}`
: undefined,
].filter((x) => x !== undefined);
};
export const keyToUrl = (key: ReturnType<typeof toQueryKey>) => {
return key.join("/").replace("/?", "?");
};
export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
let { apiUrl, authToken } = useContext(AccountContext);
if (query.options?.apiUrl) apiUrl = query.options.apiUrl;
const key = toQueryKey({ apiUrl, path: query.path, params: query.params });
return useQuery<Data, KyooError>({
queryKey: key,
queryFn: (ctx) =>
queryFn({
url: keyToUrl(key),
parser: query.parser,
signal: ctx.signal,
authToken: authToken ?? null,
...query.options,
}) as Promise<Data>,
placeholderData: query.placeholderData as any,
enabled: query.enabled,
});
};
export const useInfiniteFetch = <Data,>(query: QueryIdentifier<Data>) => {
let { apiUrl, authToken } = useContext(AccountContext);
if (query.options?.apiUrl) apiUrl = query.options.apiUrl;
const key = toQueryKey({ apiUrl, path: query.path, params: query.params });
const res = useInfiniteQuery<Page<Data>, KyooError>({
queryKey: key,
queryFn: (ctx) =>
queryFn({
url: (ctx.pageParam as string) ?? keyToUrl(key),
parser: query.parser ? Paged(query.parser) : null,
signal: ctx.signal,
authToken: authToken ?? null,
...query.options,
}) as Promise<Page<Data>>,
getNextPageParam: (page: Page<Data>) => page?.next || undefined,
initialPageParam: undefined,
placeholderData: query.placeholderData as any,
enabled: query.enabled,
});
const ret = res as typeof res & { items?: Data[] };
ret.items = ret.data?.pages.flatMap((x) => x.items);
return ret;
};
export const prefetch = async (...queries: QueryIdentifier[]) => {
const client = createQueryClient();
const authToken = undefined;
await Promise.all(
queries
.filter((x) => x.enabled !== false)
.map((query) => {
const key = toQueryKey({
apiUrl: ssrApiUrl,
path: query.path,
params: query.params,
});
if (query.infinite) {
return client.prefetchInfiniteQuery({
queryKey: key,
queryFn: (ctx) =>
queryFn({
url: keyToUrl(key),
parser: query.parser ? Paged(query.parser) : null,
signal: ctx.signal,
authToken: authToken ?? null,
...query.options,
}),
initialPageParam: undefined,
});
}
return client.prefetchQuery({
queryKey: key,
queryFn: (ctx) =>
queryFn({
url: keyToUrl(key),
parser: query.parser,
signal: ctx.signal,
authToken: authToken ?? null,
...query.options,
}),
});
}),
);
setServerData("queryState", dehydrate(client));
return client;
};
type MutationParams = {
method?: "POST" | "PUT" | "DELETE";
path?: string[];
params?: {
[query: string]: boolean | number | string | string[] | undefined;
};
body?: object;
};
export const useMutation = <T = void>({
compute,
invalidate,
...queryParams
}: MutationParams & {
compute?: (param: T) => MutationParams;
invalidate: string[] | null;
}) => {
const { apiUrl, authToken } = useContext(AccountContext);
const queryClient = useQueryClient();
const mutation = useRQMutation({
mutationFn: (param: T) => {
const { method, path, params, body } = {
...queryParams,
...compute?.(param),
} as Required<MutationParams>;
return queryFn({
method,
url: keyToUrl(toQueryKey({ apiUrl, path, params })),
body,
authToken,
parser: null,
});
},
onSuccess: invalidate
? async () =>
await queryClient.invalidateQueries({
queryKey: toQueryKey({ apiUrl, path: invalidate }),
})
: undefined,
// TODO: Do something
// onError: () => {}
});
return mutation;
};