Add zod to validate types

This commit is contained in:
Zoe Roux 2022-08-15 00:51:54 +02:00
parent 090d613266
commit dfe6fa7cda
16 changed files with 271 additions and 112 deletions

View File

@ -24,6 +24,16 @@
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
experimental: {
swcPlugins: [
[
"next-superjson-plugin",
{
excluded: [],
},
],
],
},
env: {
KYOO_URL: process.env.KYOO_URL ?? "http://localhost:5000",
},

View File

@ -26,10 +26,12 @@
"@mui/icons-material": "^5.8.4",
"@mui/material": "^5.8.7",
"next": "12.2.2",
"next-superjson-plugin": "^0.3.0",
"next-translate": "^1.5.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-query": "^4.0.0-beta.23"
"react-query": "^4.0.0-beta.23",
"zod": "^3.18.0"
},
"devDependencies": {
"@types/node": "18.0.3",

View File

@ -29,13 +29,14 @@ import {
Tooltip,
Box,
Skeleton,
AppBarProps,
} from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import logo from "../../public/icons/icon.svg";
import useTranslation from "next-translate/useTranslation";
import Image from "next/image";
import { ButtonLink } from "~/utils/link";
import { Library, Page } from "~/models";
import { LibraryP, Paged } from "~/models";
import { useFetch } from "~/utils/query";
import { ErrorSnackbar } from "./error-snackbar";
@ -63,6 +64,7 @@ export const KyooTitle = (props: { sx: SxProps<Theme> }) => {
mr: 2,
fontFamily: "monospace",
fontWeight: 700,
color: "white",
}}
>
Kyoo
@ -72,9 +74,14 @@ export const KyooTitle = (props: { sx: SxProps<Theme> }) => {
);
};
export const Navbar = () => {
export const NavbarQuery = {
parser: Paged(LibraryP),
path: ["libraries"],
};
export const Navbar = (barProps: AppBarProps) => {
const { t } = useTranslation("common");
const { data, error, isSuccess, isError } = useFetch<Page<Library>>("libraries");
const { data, error, isSuccess, isError } = useFetch(NavbarQuery);
return (
<AppBar position="sticky">
@ -111,7 +118,7 @@ export const Navbar = () => {
</Box>
<Tooltip title={t("navbar.login")}>
<IconButton sx={{ p: 0 }} href="/auth/login">
<Avatar alt="Account" />
<Avatar alt={t("navbar.login")} />
</IconButton>
</Tooltip>
</Toolbar>

View File

@ -18,9 +18,8 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Box, NoSsr, Skeleton, styled } from "@mui/material";
import { Box, Skeleton, styled } from "@mui/material";
import {
MutableRefObject,
SyntheticEvent,
useEffect,
useLayoutEffect,

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
export type { Page } from "./page";
export type { KyooErrors } from "./kyoo-errors";
export * from "./page";
export * from "./kyoo-errors";
export * from "./traits";
export * from "./resources";

View File

@ -18,6 +18,8 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { z } from "zod";
/**
* A page of resource that contains information about the pagination of resources.
*/
@ -53,3 +55,12 @@ export interface Page<T> {
*/
items: T[];
}
export const Paged = <Item>(item: z.ZodType<Item>): z.ZodSchema<Page<Item>> =>
z.object({
this: z.string(),
first: z.string(),
next: z.string().optional(),
count: z.number(),
items: z.array(item),
});

View File

@ -0,0 +1,34 @@
/*
* 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 { z } from "zod";
import { ResourceP } from "../traits/resource";
export const GenreP = ResourceP.extend({
/**
* The name of this genre.
*/
name: z.string(),
});
/**
* A genre that allow one to specify categories for shows.
*/
export type Genre = z.infer<typeof GenreP>;

View File

@ -18,19 +18,22 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Resource } from "../traits/resource";
import { z } from "zod";
import { ResourceP } from "../traits/resource";
/**
* The library that will contain Shows, Collections...
*/
export interface Library extends Resource {
export const LibraryP = ResourceP.extend({
/**
* The name of this library.
*/
name: string;
name: z.string(),
/**
* The list of paths that this library is responsible for. This is mainly used by the Scan task.
*/
paths: string[];
}
paths: z.array(z.string()),
});
export type Library = z.infer<typeof LibraryP>;

View File

@ -18,42 +18,10 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Resource, Images } from "../traits";
/**
* A series or a movie.
*/
export interface Show extends Resource, Images {
/**
* The title of this show.
*/
name: string;
/**
* The list of alternative titles of this show.
*/
aliases: string[];
/**
* The summary of this show.
*/
overview: string;
/**
* Is this show airing, not aired yet or finished?
*/
status: Status;
/**
* The date this show started airing. It can be null if this is unknown.
*/
startAir: Date | null;
/**
* The date this show finished airing. It can also be null if this is unknown.
*/
endAir: Date | null;
}
import { z } from "zod";
import { zdate } from "~/utils/zod";
import { ImagesP, ResourceP } from "../traits";
import { GenreP } from "./genre";
/**
* The enum containing show's status.
@ -64,3 +32,45 @@ export enum Status {
Airing = 2,
Planned = 3,
}
export const ShowP = z.preprocess(
(x: any) => {
x.name = x.title;
return x;
},
ResourceP.merge(ImagesP).extend({
/**
* The title of this show.
*/
name: z.string(),
/**
* The list of alternative titles of this show.
*/
aliases: z.array(z.string()),
/**
* The summary of this show.
*/
overview: z.string(),
/**
* Is this show airing, not aired yet or finished?
*/
status: z.nativeEnum(Status),
/**
* The date this show started airing. It can be null if this is unknown.
*/
startAir: zdate().optional(),
/**
* The date this show finished airing. It can also be null if this is unknown.
*/
endAir: zdate().nullable(),
/**
* The list of genres (themes) this show has.
*/
genres: z.array(GenreP).optional(),
}),
);
/**
* A tv serie or an anime.
*/
export type Show = z.infer<typeof ShowP>;

View File

@ -18,38 +18,43 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Base traits for items that has image resources.
*/
export interface Images {
import { z } from "zod";
const imageFn = (url: string) => (url.startsWith("/api") ? url : `/api${url}`);
export const ImagesP = z.object({
/**
* An url to the poster of this resource. If this resource does not have an image, the link will
* be null. If the kyoo's instance is not capable of handling this kind of image for the specific
* resource, this field won't be present.
*/
poster?: string;
poster: z.string().transform(imageFn).optional(),
/**
* An url to the thumbnail of this resource. If this resource does not have an image, the link
* will be null. If the kyoo's instance is not capable of handling this kind of image for the
* specific resource, this field won't be present.
*/
thumbnail?: string;
thumbnail: z.string().transform(imageFn).optional(),
/**
* An url to the logo of this resource. If this resource does not have an image, the link will be
* null. If the kyoo's instance is not capable of handling this kind of image for the specific
* resource, this field won't be present.
*/
logo?: string;
logo: z.string().transform(imageFn).optional(),
/**
* An url to the thumbnail of this resource. If this resource does not have an image, the link
* will be null. If the kyoo's instance is not capable of handling this kind of image for the
* specific resource, this field won't be present.
*/
trailer: z.string().transform(imageFn).optional(),
});
trailer?: string;
};
/**
* Base traits for items that has image resources.
*/
export type Images = z.infer<typeof ImagesP>;
export const imageList = ["poster", "thumbnail", "logo", "trailer"];

View File

@ -18,18 +18,22 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* The base trait used to represent identifiable resources.
*/
export interface Resource {
import { z } from "zod";
export const ResourceP = z.object({
/**
* A unique ID for this type of resource. This can't be changed and duplicates are not allowed.
*/
id: number;
id: z.number(),
/**
* A human-readable identifier that can be used instead of an ID. A slug must be unique for a type
* of resource but it can be changed.
*/
slug: string;
}
slug: z.string(),
});
/**
* The base trait used to represent identifiable resources.
*/
export type Resource = z.infer<typeof ResourceP>;

View File

@ -24,9 +24,9 @@ import { ThemeProvider } from "@mui/material";
import NextApp, { AppContext } from "next/app";
import type { AppProps } from "next/app";
import { Hydrate, QueryClientProvider } from "react-query";
import { createQueryClient, fetchQuery } from "~/utils/query";
import { createQueryClient, fetchQuery, QueryIdentifier, QueryPage } from "~/utils/query";
import { defaultTheme } from "~/utils/themes/default-theme";
import { Navbar } from "~/components/navbar";
import { Navbar, NavbarQuery } from "~/components/navbar";
import "../global.css";
import { Box } from "@mui/system";
@ -35,9 +35,7 @@ const AppWithNavbar = ({ children }: { children: JSX.Element }) => {
<>
<Navbar/>
{/* TODO: add an option to disable the navbar in the component */}
<Box >
{children}
</Box>
<Box>{children}</Box>
</>
);
};
@ -45,6 +43,8 @@ const AppWithNavbar = ({ children }: { children: JSX.Element }) => {
const App = ({ Component, pageProps }: AppProps) => {
const [queryClient] = useState(() => createQueryClient());
const { queryState, ...props } = pageProps;
// TODO: tranform date string to date instances in the queryState
return (
<QueryClientProvider client={queryClient}>
<Hydrate state={queryState}>
@ -61,10 +61,10 @@ const App = ({ Component, pageProps }: AppProps) => {
App.getInitialProps = async (ctx: AppContext) => {
const appProps = await NextApp.getInitialProps(ctx);
const getUrl = (ctx.Component as any).getFetchUrls;
const urls: string[][] = getUrl ? getUrl(ctx.router.query) : [];
const getUrl = (ctx.Component as QueryPage).getFetchUrls;
const urls: QueryIdentifier[] = getUrl ? getUrl(ctx.router.query as any) : [];
// TODO: check if the navbar is needed for this
urls.push(["libraries"]);
urls.push(NavbarQuery);
appProps.pageProps.queryState = await fetchQuery(urls);
return appProps;

View File

@ -20,8 +20,8 @@
import { Box, Typography } from "@mui/material";
import { Image, Poster } from "~/components/poster";
import { Show } from "~/models";
import { QueryPage, useFetch } from "~/utils/query";
import { Show, ShowP } from "~/models";
import { QueryIdentifier, QueryPage, useFetch } from "~/utils/query";
import { withRoute } from "~/utils/router";
const ShowHeader = (data: Show) => {
@ -36,8 +36,17 @@ const ShowHeader = (data: Show) => {
);
};
const query = (slug: string): QueryIdentifier<Show> => ({
parser: ShowP,
path: ["shows", slug],
params: {
fields: ["genres"],
},
});
const ShowDetails: QueryPage<{ slug: string }> = ({ slug }) => {
const { data } = useFetch<Show>("shows", slug);
const { data, error } = useFetch(query(slug));
console.log("error", data);
if (!data) return <p>oups</p>;
@ -48,6 +57,6 @@ const ShowDetails: QueryPage<{ slug: string }> = ({ slug }) => {
);
};
ShowDetails.getFetchUrls = ({ slug }) => [["shows", slug]];
ShowDetails.getFetchUrls = ({ slug }) => [query(slug)];
export default withRoute(ShowDetails);

View File

@ -26,22 +26,37 @@ import {
useInfiniteQuery,
useQuery,
} from "react-query";
import { z } from "zod";
import { imageList, KyooErrors, Page } from "~/models";
import { Paged } from "~/models/page";
const queryFn = async <T>(context: QueryFunctionContext): Promise<T> => {
const queryFn = async <Data>(
type: z.ZodType<Data>,
context: QueryFunctionContext,
): Promise<Data> => {
try {
const resp = await fetch(
[typeof window === "undefined" ? process.env.KYOO_URL : "/api"]
.concat(context.pageParam ? [context.pageParam] : (context.queryKey as string[]))
.join("/"),
.concat(
context.pageParam ? [context.pageParam] : (context.queryKey.filter((x) => x) as string[]),
)
.join("/")
.replace("/?", "?"),
);
if (!resp.ok) {
throw await resp.json();
}
return await resp.json();
const data = await resp.json();
const parsed = await type.safeParseAsync(data);
if (!parsed.success) {
console.log("Parse error: ", parsed.error);
throw { errors: parsed.error.errors.map((x) => x.message) } as KyooErrors;
}
return parsed.data;
} catch (e) {
console.error(e);
throw { errors: ["Could not reach Kyoo's server."] }; // as KyooErrors;
console.error("Fetch error: ", e);
throw { errors: ["Could not reach Kyoo's server."] } as KyooErrors;
}
};
@ -53,52 +68,57 @@ export const createQueryClient = () =>
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
queryFn: queryFn,
},
},
});
export type QueryIdentifier<T = unknown> = {
parser: z.ZodType<T>;
path: string[];
params?: { [query: string]: boolean | number | string | string[] };
};
export type QueryPage<Props = {}> = ComponentType<Props> & {
getFetchUrls?: (route: { [key: string]: string }) => string[][];
getFetchUrls?: (route: { [key: string]: string }) => QueryIdentifier[];
};
const imageSelector = <T>(obj: T): T => {
// TODO: remove this
// @ts-ignore
if ("title" in obj) obj.name = obj.title;
for (const img of imageList) {
// @ts-ignore
if (img in obj && obj[img] && !obj[img].startsWith("/api")) {
// @ts-ignore
obj[img] = `/api${obj[img]}`;
}
}
return obj;
const toQuery = (params?: { [query: string]: boolean | number | string | string[] }) => {
if (!params) return undefined;
return (
"?" +
Object.entries(params)
.map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`)
.join("&")
);
};
export const useFetch = <Data>(...params: string[]) => {
return useQuery<Data, KyooErrors>(params, {
select: imageSelector,
export const useFetch = <Data>(query: QueryIdentifier<Data>) => {
return useQuery<Data, KyooErrors>({
queryKey: [...query.path, toQuery(query.params)],
queryFn: (ctx) => queryFn(query.parser, ctx),
});
};
export const useInfiniteFetch = <Data>(...params: string[]) => {
return useInfiniteQuery<Page<Data>, KyooErrors>(params, {
select: (pages) => {
pages.pages.map((x) => x.items.map(imageSelector));
return pages;
},
export const useInfiniteFetch = <Data>(query: QueryIdentifier<Data>) => {
return useInfiniteQuery<Page<Data>, KyooErrors>({
queryKey: [...query.path, toQuery(query.params)],
queryFn: (ctx) => queryFn(Paged(query.parser), ctx),
});
};
export const fetchQuery = async (queries: string[][]) => {
export const fetchQuery = async (queries: QueryIdentifier[]) => {
// 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 {};
console.log(queries)
const client = createQueryClient();
await Promise.all(queries.map((x) => client.prefetchQuery(x)));
await Promise.all(
queries.map((query) =>
client.prefetchQuery({
queryKey: [...query.path, toQuery(query.params)],
queryFn: (ctx) => queryFn(query.parser, ctx),
}),
),
);
return dehydrate(client);
};

33
front/src/utils/zod.ts Normal file
View File

@ -0,0 +1,33 @@
/*
* 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 { z } from "zod";
export const zdate = () => {
return z.preprocess((arg) => {
if (arg instanceof Date) return arg;
if (typeof arg === "string" && /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/.test(arg)) {
return new Date(arg);
}
return undefined;
}, z.date());
};

View File

@ -1385,7 +1385,7 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"
hoist-non-react-statics@^3.3.1:
hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
@ -1938,6 +1938,13 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
next-superjson-plugin@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/next-superjson-plugin/-/next-superjson-plugin-0.3.0.tgz#81145f275c1e555be68867c104cc21113f96c675"
integrity sha512-M0Soj1P2t9peCyzNndEJiS48O2m88X9UGsCXDy8WHyGwWw1S7eCOEg9MiMqR+X1GD5C3hsdtHKMsKqrZOzr+SQ==
dependencies:
hoist-non-react-statics "^3.3.2"
next-translate@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/next-translate/-/next-translate-1.5.0.tgz#b1e5c4a8e55e31b3ed1b9428529f27c289c6b7bc"
@ -2583,3 +2590,8 @@ yaml@^1.7.2:
version "1.10.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
zod@^3.18.0:
version "3.18.0"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.18.0.tgz#2eed58b3cafb8d9a67aa2fee69279702f584f3bc"
integrity sha512-gwTm8RfUCe8l9rDwN5r2A17DkAa8Ez4Yl4yXqc5VqeGaXaJahzYYXbTwvhroZi0SNBqTwh/bKm2N0mpCzuw4bA==