Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🏷️ Add API return types and validate with zod #11

Draft
wants to merge 6 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/components/atoms/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Link from "next/link";
import { ChevronDown, ChevronUp } from "react-feather";
import { useTranslation } from "react-i18next";

import { BaseBoard } from "@/types";
import type { Board } from "@/types";
import i18n from "@/utils/i18n";

import * as styles from "./Dropdown.css";
Expand All @@ -29,7 +29,7 @@ const Chevron: React.FC<ChevronProps> = ({ openOnHover, isOpened }) => {

type DropdownProps = {
title: string;
boards: BaseBoard[];
boards: Board[];
} & (
| { openOnHover: true; isOpened?: never; onClick?: never }
| { openOnHover: false; isOpened: boolean; onClick: () => void }
Expand Down Expand Up @@ -60,7 +60,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
{boards.map((board) => (
<li key={board.id} className={styles.dropdownAnchorWrapper}>
<Link href="#" className={styles.dropdownAnchor}>
{i18n.language === "ko_KR" ? board.koName : board.enName}
{i18n.language === "ko_KR" ? board.name.ko : board.name.en}
</Link>
</li>
))}
Expand Down
8 changes: 4 additions & 4 deletions src/components/molecules/Header/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ export const HoverDropdowns: React.FC = () => {

return (
<>
{boardGroups.map(({ id, koName, enName, boards }) => (
{boardGroups.map(({ id, name, boards }) => (
<Item key={id}>
<Dropdown
title={i18n.language === "ko_KR" ? koName : enName}
title={i18n.language === "ko_KR" ? name.ko : name.en}
boards={boards}
openOnHover={true}
/>
Expand All @@ -31,10 +31,10 @@ export const ClickableDropdowns: React.FC = () => {

return (
<>
{boardGroups?.map(({ id, koName, enName, boards }) => (
{boardGroups?.map(({ id, name, boards }) => (
<Item key={id}>
<Dropdown
title={i18n.language === "ko_KR" ? koName : enName}
title={i18n.language === "ko_KR" ? name.ko : name.en}
boards={boards}
openOnHover={false}
isOpened={openedGroupId === id}
Expand Down
18 changes: 9 additions & 9 deletions src/constants/enum.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// FIXME: Use better way to define enum
export const enum NameType {
NICKNAME = 1,
ANONYMOUS = 2,
NICKNAME_ANONYMOUS = 3,
REALNAME = 4,
NICKNAME_REALNAME = 5,
ANONYMOUS_REALNAME = 6,
NICKNAME_ANONYMOUS_REALNAME = 7,
}
export const NameType = {
NICKNAME: 1,
ANONYMOUS: 2,
NICKNAME_ANONYMOUS: 3,
REALNAME: 4,
NICKNAME_REALNAME: 5,
ANONYMOUS_REALNAME: 6,
NICKNAME_ANONYMOUS_REALNAME: 7,
} as const;
24 changes: 17 additions & 7 deletions src/lib/api/board.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { client } from "@/lib/axios";
import type { Board, BoardGroup } from "@/types";
import { boardGroupSchema, boardSchema } from "@/types";

export const getBoards = async () => (await client.get<Board[]>("boards/")).data;
export const getBoards = async () => {
const boards = (await client.get("boards/")).data;
return boardSchema.array().parse(boards);
};

export const getBoardBySlug = async (slug: string) =>
(await client.get<Board>(`boards/${slug}/`)).data;
export const getBoardBySlug = async (slug: string) => {
const board = (await client.get(`boards/${slug}/`)).data;
return boardSchema.parse(board);
};

export const getBoardGroups = async () => (await client.get<BoardGroup[]>("board_groups/")).data;
export const getBoardGroups = async () => {
const boardGroups = (await client.get("board_groups/")).data;
return boardGroupSchema.array().parse(boardGroups);
};

export const getBoardGroupBySlug = async (slug: string) =>
(await client.get<BoardGroup>(`board_groups/${slug}/`)).data;
export const getBoardGroupBySlug = async (slug: string) => {
const boardGroup = (await client.get(`board_groups/${slug}/`)).data;
return boardGroupSchema.parse(boardGroup);
};
23 changes: 23 additions & 0 deletions src/lib/api/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { client } from "@/lib/axios";
import { notificationSchema, paginationSchema } from "@/types";

export type GetNotificationsOptions = {
page?: number;
unread?: boolean;
};

export const getNotifications = async (options?: GetNotificationsOptions) => {
const notifications = (
await client.get("notifications/", {
params: {
page: options?.page,
is_read: options?.unread === undefined ? undefined : !options.unread,
},
})
).data;
return paginationSchema(notificationSchema).parse(notifications);
};

export const readNotification = async (id: number) => {
await client.post(`notifications/${id}/read/`);
};
1 change: 1 addition & 0 deletions src/lib/queries/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const useBoards = () =>
useQuery({
queryKey: ["boards"],
queryFn: getBoards,
staleTime: Infinity,
});

export const useBoardBySlug = (slug: string) => {
Expand Down
1 change: 1 addition & 0 deletions src/lib/queries/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./board";
export * from "./notification";
25 changes: 25 additions & 0 deletions src/lib/queries/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

import {
type GetNotificationsOptions,
getNotifications,
readNotification,
} from "@/lib/api/notification";

const NOTIFICATION_QUERY_KEY = "notifications";

export const useNotifications = (options?: GetNotificationsOptions) =>
useQuery({
queryKey: [NOTIFICATION_QUERY_KEY, options],
queryFn: () => getNotifications(options),
});

export const useReadNotification = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: readNotification,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [NOTIFICATION_QUERY_KEY] });
},
});
};
98 changes: 65 additions & 33 deletions src/types/board.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,69 @@
import type { NameType } from "@/constants/enum";
import { z } from "zod";

export type BaseBoard = {
id: number;
slug: string;
koName: string;
enName: string;
};
import { NameType } from "@/constants/enum";

export type Board = BaseBoard & {
isReadonly: boolean;
nameType: NameType;
group: BaseBoardGroup;
topics: {
id: number;
slug: string;
koName: string;
enName: string;
}[];
bannerImage: string;
koBannerDescription: string;
enBannerDescription: string;
topThreshold: number;
userReadable: boolean;
userWritable: boolean;
};
const baseSchema = z.object({
id: z.number(),
slug: z.string(),
koName: z.string(),
enName: z.string(),
});
type Base = z.infer<typeof baseSchema>;
const baseTransformer = ({ id, slug, koName, enName }: Base) => ({
id,
slug,
name: {
ko: koName,
en: enName,
},
});

type BaseBoardGroup = {
id: number;
slug: string;
koName: string;
enName: string;
};
export const topicSchema = baseSchema;

export type BoardGroup = BaseBoardGroup & {
boards: BaseBoard[];
};
const rawBoardSchema = baseSchema.extend({
isReadonly: z.boolean(),
nameType: z.nativeEnum(NameType),
group: baseSchema,
topics: topicSchema.array(),
bannerImage: z.string().url(),
koBannerDescription: z.string(),
enBannerDescription: z.string(),
topThreshold: z.number(),
userReadable: z.boolean(),
userWritable: z.boolean(),
});
type RawBoard = z.infer<typeof rawBoardSchema>;
const boardTransformer = (board: RawBoard) => ({
id: board.id,
slug: board.slug,
name: {
ko: board.koName,
en: board.enName,
},
nameType: board.nameType,
group: baseTransformer(board.group),
topics: board.topics.map(baseTransformer),
banner: {
image: board.bannerImage,
description: {
ko: board.koBannerDescription,
en: board.enBannerDescription,
},
},
topThreshold: board.topThreshold,
isReadOnly: board.isReadonly,
isReadable: board.userReadable,
isWritable: board.userWritable,
});
export const boardSchema = rawBoardSchema.transform(boardTransformer);
export type Board = z.infer<typeof boardSchema>;

export const boardGroupSchema = baseSchema
.extend({
boards: rawBoardSchema.array(),
})
.transform(({ boards, ...rest }) => ({
...baseTransformer(rest),
boards: boards.map(boardTransformer),
}));
export type BoardGroup = z.infer<typeof boardGroupSchema>;
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from "./board";
export * from "./notification";
export * from "./pagination";
40 changes: 40 additions & 0 deletions src/types/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { z } from "zod";

import { NameType } from "@/constants/enum";

const baseSchema = z.object({
id: z.number(),
createdAt: z.coerce.date(),
title: z.string(),
content: z.string(),
isRead: z.boolean(),
relatedArticle: z.object({
id: z.number(),
createdAt: z.coerce.date(),
createdBy: z.number(),
title: z.string(),
nameType: z.nativeEnum(NameType),
parentBoard: z.number(),
parentTopic: z.number().nullable(),
}),
});
const commentNotificationSchema = baseSchema
.extend({
type: z.literal("article_commented"),
relatedComment: z.null(),
})
.transform(({ type, ...data }) => ({ type: "comment" as const, ...data }));
const replyNotificationSchema = baseSchema
.extend({
type: z.literal("comment_commented"),
relatedComment: z.object({
id: z.number(),
createdAt: z.coerce.date(),
createdBy: z.number(),
content: z.string(),
nameType: z.nativeEnum(NameType),
}),
})
.transform(({ type, ...data }) => ({ type: "reply" as const, ...data }));
export const notificationSchema = z.union([commentNotificationSchema, replyNotificationSchema]);
export type Notification = z.infer<typeof notificationSchema>;
11 changes: 11 additions & 0 deletions src/types/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from "zod";

export const paginationSchema = <T extends z.ZodTypeAny>(schema: T) =>
z.object({
numPages: z.number(),
numItems: z.number(),
current: z.number(),
previous: z.string().url().nullable(),
next: z.string().url().nullable(),
results: z.array(schema),
});
52 changes: 35 additions & 17 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,26 +1,44 @@
{
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
/* Type Checking */
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"strict": true,

/* Modules */
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"@/*": ["./src/*"]
},
"resolveJsonModule": true,

/* Emit */
"noEmit": true,

/* JavaScript Support */
"allowJs": true,

/* Editor Support */
"plugins": [{ "name": "next" }],

/* Interop Constraints */
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,

/* Language and Environment */
"jsx": "preserve",
"lib": ["DOM", "DOM.Iterable", "ESNext"],

/* Projects */
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]

/* Completeness */
"skipLibCheck": true
}
}