2023-11-06 01:24:55 +01:00

252 lines
7.1 KiB
TypeScript

/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { ComponentType, ReactElement } from "react";
import {
dehydrate,
QueryClient,
QueryFunctionContext,
QueryKey,
useInfiniteQuery,
useQuery,
} from "@tanstack/react-query";
import { z } from "zod";
import { KyooErrors } from "./kyoo-errors";
import { Page, Paged } from "./page";
import { Platform } from "react-native";
import { getToken } from "./login";
const kyooUrl =
Platform.OS !== "web"
? process.env.PUBLIC_BACK_URL
: typeof window === "undefined"
? process.env.KYOO_URL ?? "http://localhost:5000"
: "/api";
export let kyooApiUrl: string | null = kyooUrl || null;
export const setApiUrl = (apiUrl: string) => {
kyooApiUrl = apiUrl;
};
export const queryFn = async <Data,>(
context:
| QueryFunctionContext
| {
path: (string | false | undefined | null)[];
body?: object;
method: "GET" | "POST" | "DELETE";
authenticated?: boolean;
apiUrl?: string;
abortSignal?: AbortSignal;
},
type?: z.ZodType<Data>,
token?: string | null,
): Promise<Data> => {
// @ts-ignore
let url: string | null = context.apiUrl ?? kyooApiUrl;
if (!url) console.error("Kyoo's url is not defined.");
kyooApiUrl = url;
// @ts-ignore
if (!token && context.authenticated !== false) token = await getToken();
const path = [url]
.concat(
"path" in context
? (context.path.filter((x) => x) as string[])
: "pageParam" in context
? [context.pageParam as string]
: (context.queryKey.filter((x, i) => x && i) as string[]),
)
.join("/")
.replace("/?", "?");
let resp;
try {
resp = await fetch(path, {
// @ts-ignore
method: context.method,
// @ts-ignore
body: context.body ? JSON.stringify(context.body) : undefined,
headers: {
...(token ? { Authorization: token } : {}),
...("body" in context ? { "Content-Type": "application/json" } : {}),
},
signal: "abortSignal" in context ? context.abortSignal : undefined,
});
} catch (e) {
console.log("Fetch error", e);
throw { errors: ["Could not reach Kyoo's server."] } as KyooErrors;
}
if (resp.status === 404) {
throw { errors: ["Resource not found."] } as KyooErrors;
}
if (!resp.ok) {
const error = await resp.text();
let data;
try {
data = JSON.parse(error);
} catch (e) {
data = { errors: [error] } as KyooErrors;
}
console.log(
`Invalid response (${
"method" in context && context.method ? context.method : "GET"
} ${path}):`,
data,
resp.status,
);
throw data as KyooErrors;
}
// If the method is DELETE, 204 NoContent is returned from kyoo.
// @ts-ignore
if (context.method === "DELETE") return undefined;
let data;
try {
data = await resp.json();
} catch (e) {
console.error("Invald json from kyoo", e);
throw { errors: ["Invalid repsonse from kyoo"] };
}
if (!type) return data;
const parsed = await type.safeParseAsync(data);
if (!parsed.success) {
console.log("Path: ", path, " Response: ", resp.status, " Parse error: ", parsed.error);
throw {
errors: [
"Invalid response from kyoo. Possible version mismatch between the server and the application.",
],
} as KyooErrors;
}
return parsed.data;
};
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
},
});
export type QueryIdentifier<T = unknown, Ret = T> = {
parser: z.ZodType<T, z.ZodTypeDef, any>;
path: (string | undefined)[];
params?: { [query: string]: boolean | number | string | string[] | undefined };
infinite?: boolean | { value: true; map?: (x: any[]) => Ret[] };
/**
* A custom get next function if the infinite query is not a page.
*/
getNext?: (item: unknown) => string | undefined;
};
export type QueryPage<Props = {}, Items = unknown> = ComponentType<
Props & { randomItems: Items[] }
> & {
getFetchUrls?: (route: { [key: string]: string }) => QueryIdentifier<any>[];
getLayout?:
| QueryPage<{ page: ReactElement }>
| { Layout: QueryPage<{ page: ReactElement }>; props: object };
randomItems?: Items[];
};
const toQueryKey = <Data, Ret>(query: QueryIdentifier<Data, Ret>) => {
const prefix = Platform.OS !== "web" ? [kyooApiUrl] : [""];
if (query.params) {
return [
...prefix,
...query.path,
"?" +
Object.entries(query.params)
.filter(([_, v]) => v !== undefined)
.map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`)
.join("&"),
];
} else {
return [...prefix, ...query.path];
}
};
export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
return useQuery<Data, KyooErrors>({
queryKey: toQueryKey(query),
queryFn: (ctx) => queryFn(ctx, query.parser),
});
};
export const useInfiniteFetch = <Data, Ret>(query: QueryIdentifier<Data, Ret>) => {
if (query.getNext) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const ret = useInfiniteQuery<Data[], KyooErrors>({
queryKey: toQueryKey(query),
queryFn: (ctx) => queryFn(ctx, z.array(query.parser)),
getNextPageParam: query.getNext,
initialPageParam: undefined,
});
return { ...ret, items: ret.data?.pages.flatMap((x) => x) as unknown as Ret[] | undefined };
}
// eslint-disable-next-line react-hooks/rules-of-hooks
const ret = useInfiniteQuery<Page<Data>, KyooErrors>({
queryKey: toQueryKey(query),
queryFn: (ctx) => queryFn(ctx, Paged(query.parser)),
getNextPageParam: (page: Page<Data>) => page?.next || undefined,
initialPageParam: undefined,
});
const items = ret.data?.pages.flatMap((x) => x.items);
return {
...ret,
items:
items && typeof query.infinite === "object" && query.infinite.map
? query.infinite.map(items)
: (items as unknown as Ret[] | undefined),
};
};
export const fetchQuery = async (queries: QueryIdentifier[], authToken?: string | null) => {
// we can't put this check in a function because we want build time optimizations
// see https://github.com/vercel/next.js/issues/5354 for details
if (typeof window !== "undefined") return {};
const client = createQueryClient();
await Promise.all(
queries.map((query) => {
if (query.infinite) {
return client.prefetchInfiniteQuery({
queryKey: toQueryKey(query),
queryFn: (ctx) => queryFn(ctx, Paged(query.parser), authToken),
initialPageParam: undefined,
});
} else {
return client.prefetchQuery({
queryKey: toQueryKey(query),
queryFn: (ctx) => queryFn(ctx, query.parser, authToken),
});
}
}),
);
return dehydrate(client);
};