Rework query function

This commit is contained in:
Zoe Roux 2025-02-02 23:13:57 +01:00
parent c1e3a67a4e
commit 5d6bb63ba2
No known key found for this signature in database
2 changed files with 50 additions and 88 deletions

View File

@ -1,7 +1,7 @@
import { Text, View } from "react-native";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { LibraryItem, LibraryItemP, type News, NewsP } from "~/models"; import { type LibraryItem, LibraryItemP } from "~/models";
import type { QueryIdentifier } from "~/query/index"; import { P } from "~/primitives";
import { Fetch, type QueryIdentifier } from "~/query";
export async function loader() { export async function loader() {
await prefetchQuery(Header.query()); await prefetchQuery(Header.query());
@ -12,15 +12,9 @@ export default function Header() {
return ( return (
<Fetch <Fetch
query={NewsList.query()} query={Header.query()}
layout={{ ...ItemGrid.layout, layout: "horizontal" }} Render={({ name }) => <P {...css({ bg: "red" })}>{name}</P>}
getItemType={(x, i) => (x?.kind === "movie" || (!x && i % 2) ? "movie" : "episode")} Loader={() => <P>Loading</P>}
getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
empty={t("home.none")}
Render={({ item }) => {
<Text>{item.name}</Text>;
}}
Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)}
/> />
); );
} }

View File

@ -1,15 +1,7 @@
import { import { QueryClient, useInfiniteQuery, useQuery } from "@tanstack/react-query";
QueryClient, import { type ComponentType, type ReactElement, useContext } from "react";
type QueryFunctionContext,
useInfiniteQuery,
useQuery,
} from "@tanstack/react-query";
import type { ComponentType, ReactElement } from "react";
import type { z } from "zod"; import type { z } from "zod";
import { type KyooError, type Page, Paged } from "~/models"; import { type KyooError, type Page, Paged } from "~/models";
// import { getToken, getTokenWJ } from "./login";
export let lastUsedUrl: string = null!;
const cleanSlash = (str: string | null, keepFirst = false) => { const cleanSlash = (str: string | null, keepFirst = false) => {
if (!str) return null; if (!str) return null;
@ -17,41 +9,19 @@ const cleanSlash = (str: string | null, keepFirst = false) => {
return str.replace(/^\/|\/$/g, ""); return str.replace(/^\/|\/$/g, "");
}; };
export const queryFn = async <Parser extends z.ZodTypeAny>( export const queryFn = async <Parser extends z.ZodTypeAny>(context: {
context: { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
apiUrl?: string | null; url: string;
authenticated?: boolean; body?: object;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; formData?: FormData;
} & ( plainText?: boolean;
| QueryFunctionContext authToken: string | null;
| ({ parser?: Parser;
path: (string | false | undefined | null)[]; signal: AbortSignal;
body?: object; }): Promise<z.infer<Parser>> => {
formData?: FormData;
plainText?: boolean;
} & Partial<QueryFunctionContext>)
),
type?: Parser,
iToken?: string | null,
): Promise<z.infer<Parser>> => {
const url = context.apiUrl && context.apiUrl.length > 0 ? context.apiUrl : getCurrentApiUrl();
lastUsedUrl = url!;
const token = iToken === undefined && context.authenticated !== false ? await getToken() : iToken;
const path = [cleanSlash(url, true)]
.concat(
"path" in context
? (context.path as string[])
: "pageParam" in context && context.pageParam
? [cleanSlash(context.pageParam as string)]
: (context.queryKey as string[]),
)
.filter((x) => x)
.join("/")
.replace("/?", "?");
let resp: Response; let resp: Response;
try { try {
resp = await fetch(path, { resp = await fetch(context.url, {
method: context.method, method: context.method,
body: body:
"body" in context && context.body "body" in context && context.body
@ -60,7 +30,7 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(
? context.formData ? context.formData
: undefined, : undefined,
headers: { headers: {
...(token ? { Authorization: token } : {}), ...(context.authToken ? { Authorization: context.authToken } : {}),
...("body" in context ? { "Content-Type": "application/json" } : {}), ...("body" in context ? { "Content-Type": "application/json" } : {}),
}, },
signal: context.signal, signal: context.signal,
@ -68,20 +38,12 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(
} catch (e) { } catch (e) {
if (typeof e === "object" && e && "name" in e && e.name === "AbortError") if (typeof e === "object" && e && "name" in e && e.name === "AbortError")
throw { message: "Aborted", status: "aborted" } as KyooError; throw { message: "Aborted", status: "aborted" } as KyooError;
console.log("Fetch error", e, path); console.log("Fetch error", e, context.url);
throw { message: "Could not reach Kyoo's server.", status: "aborted" } as KyooError; throw { message: "Could not reach Kyoo's server.", status: "aborted" } as KyooError;
} }
if (resp.status === 404) { if (resp.status === 404) {
throw { message: "Resource not found.", status: 404 } as KyooError; throw { message: "Resource not found.", status: 404 } as KyooError;
} }
// If we got a forbidden, try to refresh the token
// if we got a token as an argument, it either means we already retried or we go one provided that's fresh
// so we can't retry either ways.
if (resp.status === 403 && iToken === undefined && token) {
const [newToken, _, error] = await getTokenWJ(undefined, true);
if (newToken) return await queryFn(context, type, newToken);
console.error("refresh error while retrying a forbidden", error);
}
if (!resp.ok) { if (!resp.ok) {
const error = await resp.text(); const error = await resp.text();
let data: Record<string, any>; let data: Record<string, any>;
@ -92,9 +54,7 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(
} }
data.status = resp.status; data.status = resp.status;
console.trace( console.trace(
`Invalid response (${ `Invalid response (${context.method ?? "GET"} ${context.url}):`,
"method" in context && context.method ? context.method : "GET"
} ${path}):`,
data, data,
resp.status, resp.status,
); );
@ -103,7 +63,7 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(
if (resp.status === 204) return null; if (resp.status === 204) return null;
if ("plainText" in context && context.plainText) return (await resp.text()) as unknown; if (context.plainText) return (await resp.text()) as unknown;
let data: Record<string, any>; let data: Record<string, any>;
try { try {
@ -112,10 +72,10 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(
console.error("Invalid json from kyoo", e); console.error("Invalid json from kyoo", e);
throw { message: "Invalid response from kyoo", status: "json" } as KyooError; throw { message: "Invalid response from kyoo", status: "json" } as KyooError;
} }
if (!type) return data; if (!context.parser) return data;
const parsed = await type.safeParseAsync(data); const parsed = await context.parser.safeParseAsync(data);
if (!parsed.success) { if (!parsed.success) {
console.log("Path: ", path, " Response: ", resp.status, " Parse error: ", parsed.error); console.log("Url: ", context.url, " Response: ", resp.status, " Parse error: ", parsed.error);
throw { throw {
status: "parse", status: "parse",
message: message:
@ -178,12 +138,12 @@ export type QueryPage<Props = {}, Items = unknown> = ComponentType<
}; };
export const toQueryKey = (query: { export const toQueryKey = (query: {
apiUrl: string;
path: (string | undefined)[]; path: (string | undefined)[];
params?: { [query: string]: boolean | number | string | string[] | undefined }; params?: { [query: string]: boolean | number | string | string[] | undefined };
options?: { apiUrl?: string | null };
}) => { }) => {
return [ return [
query.options?.apiUrl, cleanSlash(query.apiUrl, true),
...query.path, ...query.path,
query.params query.params
? `?${Object.entries(query.params) ? `?${Object.entries(query.params)
@ -195,30 +155,38 @@ export const toQueryKey = (query: {
}; };
export const useFetch = <Data,>(query: QueryIdentifier<Data>) => { export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
const { apiUrl, authToken } = useContext(QueryContext);
const key = toQueryKey({ apiUrl, path: query.path, params: query.params });
return useQuery<Data, KyooError>({ return useQuery<Data, KyooError>({
queryKey: toQueryKey(query), queryKey: key,
queryFn: (ctx) => queryFn: (ctx) =>
queryFn( queryFn({
{ url: key.join("/").replace("/?", "?"),
...ctx, parser: query.parser,
queryKey: toQueryKey({ ...query, options: {} }), signal: ctx.signal,
...query.options, authToken,
}, ...query.options,
query.parser, }),
),
placeholderData: query.placeholderData as any, placeholderData: query.placeholderData as any,
enabled: query.enabled, enabled: query.enabled,
}); });
}; };
export const useInfiniteFetch = <Data, Ret>(query: QueryIdentifier<Data, Ret>) => { export const useInfiniteFetch = <Data, Ret>(query: QueryIdentifier<Data, Ret>) => {
const { apiUrl, authToken } = useContext(QueryContext);
const key = toQueryKey({ apiUrl, path: query.path, params: query.params });
const ret = useInfiniteQuery<Page<Data>, KyooError>({ const ret = useInfiniteQuery<Page<Data>, KyooError>({
queryKey: toQueryKey(query), queryKey: key,
queryFn: (ctx) => queryFn: (ctx) =>
queryFn( queryFn({
{ ...ctx, queryKey: toQueryKey({ ...query, options: {} }), ...query.options }, url: (ctx.pageParam as string) ?? key.join("/").replace("/?", "?"),
Paged(query.parser), parser: Paged(query.parser),
), signal: ctx.signal,
authToken,
...query.options,
}),
getNextPageParam: (page: Page<Data>) => page?.next || undefined, getNextPageParam: (page: Page<Data>) => page?.next || undefined,
initialPageParam: undefined, initialPageParam: undefined,
placeholderData: query.placeholderData as any, placeholderData: query.placeholderData as any,