Skip to content

Commit 28504a9

Browse files
authored
Merge pull request #197 from ut-code/copilot/add-zod-validation-server-actions
Add Zod validation to server actions and API chat route; replace root interfaces with Zod schemas
2 parents a909916 + f759e87 commit 28504a9

File tree

12 files changed

+113
-76
lines changed

12 files changed

+113
-76
lines changed

app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@ import { useState, FormEvent, useEffect } from "react";
77
// QuestionExampleParams,
88
// } from "../actions/questionExample";
99
// import { getLanguageName } from "../pagesList";
10-
import { DynamicMarkdownSection } from "./pageContent";
1110
import { useEmbedContext } from "@/terminal/embedContext";
12-
import { PagePath } from "@/lib/docs";
11+
import { DynamicMarkdownSection, PagePath } from "@/lib/docs";
1312
import { useRouter } from "next/navigation";
1413
import { ChatStreamEvent } from "@/api/chat/route";
1514
import { useStreamingChatContext } from "@/(docs)/streamingChatContext";

app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,18 @@ import { useSidebarMdContext } from "@/sidebar";
77
import clsx from "clsx";
88
import { PageTransition } from "./pageTransition";
99
import {
10+
DynamicMarkdownSection,
1011
LanguageEntry,
1112
MarkdownSection,
1213
PageEntry,
1314
PagePath,
1415
SectionId,
1516
} from "@/lib/docs";
16-
import { ReplacedRange } from "@/markdown/multiHighlight";
1717
import { Heading } from "@/markdown/heading";
1818
import Link from "next/link";
1919
import { useChatId } from "@/(docs)/chatAreaState";
2020
import { ChatWithMessages } from "@/lib/chatHistory";
2121

22-
/**
23-
* MarkdownSectionに追加で、動的な情報を持たせる
24-
*/
25-
export interface DynamicMarkdownSection extends MarkdownSection {
26-
/**
27-
* ユーザーが今そのセクションを読んでいるかどうか
28-
*/
29-
inView: boolean;
30-
/**
31-
* チャットの会話を元にAIが書き換えた後の内容
32-
*/
33-
replacedContent: string;
34-
replacedRange: ReplacedRange[];
35-
}
36-
3722
interface PageContentProps {
3823
splitMdContent: MarkdownSection[];
3924
langEntry: LanguageEntry;

app/actions/deleteChat.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"use server";
22

3+
import { z } from "zod";
34
import { deleteChat, initContext } from "@/lib/chatHistory";
45

56
export async function deleteChatAction(chatId: string) {
7+
chatId = z.uuid().parse(chatId);
68
const ctx = await initContext();
79
await deleteChat(chatId, ctx);
810
}

app/actions/getRedirectFromChat.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"use server";
22

3+
import { z } from "zod";
34
import { initContext } from "@/lib/chatHistory";
45
import { LangId, PageSlug } from "@/lib/docs";
56
import { chat, section } from "@/schema/chat";
67
import { and, eq } from "drizzle-orm";
78

89
export async function getRedirectFromChat(chatId: string): Promise<string> {
10+
chatId = z.uuid().parse(chatId);
11+
912
const { drizzle, userId } = await initContext();
1013
if (!userId) {
1114
throw new Error("Not authenticated");

app/api/chat/route.ts

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,27 @@ import {
66
CreateChatDiff,
77
initContext,
88
} from "@/lib/chatHistory";
9-
import { getPagesList, introSectionId, PagePath, SectionId } from "@/lib/docs";
10-
import { DynamicMarkdownSection } from "@/(docs)/@docs/[lang]/[pageId]/pageContent";
11-
import { ReplCommand, ReplOutput } from "@my-code/runtime/interface";
9+
import {
10+
DynamicMarkdownSectionSchema,
11+
getPagesList,
12+
introSectionId,
13+
PagePathSchema,
14+
SectionId,
15+
} from "@/lib/docs";
16+
import {
17+
ReplCommandSchema,
18+
ReplOutputSchema,
19+
} from "@my-code/runtime/interface";
20+
import { z } from "zod";
1221

13-
type ChatParams = {
14-
path: PagePath;
15-
userQuestion: string;
16-
sectionContent: DynamicMarkdownSection[];
17-
replOutputs: Record<string, ReplCommand[]>;
18-
files: Record<string, string>;
19-
execResults: Record<string, ReplOutput[]>;
20-
};
22+
const ChatParamsSchema = z.object({
23+
path: PagePathSchema,
24+
userQuestion: z.string().min(1),
25+
sectionContent: z.array(DynamicMarkdownSectionSchema),
26+
replOutputs: z.record(z.string(), z.array(ReplCommandSchema)),
27+
files: z.record(z.string(), z.string()),
28+
execResults: z.record(z.string(), z.array(ReplOutputSchema)),
29+
});
2130

2231
export type ChatStreamEvent =
2332
| { type: "chat"; chatId: string; sectionId: string }
@@ -31,9 +40,18 @@ export async function POST(request: NextRequest) {
3140
return new Response("Unauthorized", { status: 401 });
3241
}
3342

34-
const params = (await request.json()) as ChatParams;
35-
const { path, userQuestion, sectionContent, replOutputs, files, execResults } =
36-
params;
43+
const parseResult = ChatParamsSchema.safeParse(await request.json());
44+
if (!parseResult.success) {
45+
return new Response(JSON.stringify(parseResult.error), { status: 400 });
46+
}
47+
const {
48+
path,
49+
userQuestion,
50+
sectionContent,
51+
replOutputs,
52+
files,
53+
execResults,
54+
} = parseResult.data;
3755

3856
const pagesList = await getPagesList();
3957
const langName = pagesList.find((lang) => lang.id === path.lang)?.name;
@@ -202,7 +220,7 @@ export async function POST(request: NextRequest) {
202220
prompt.join("\n")
203221
)) {
204222
console.log("Received chunk:", [chunk]);
205-
223+
206224
fullText += chunk;
207225

208226
if (!headerParsed) {
@@ -294,9 +312,7 @@ export async function POST(request: NextRequest) {
294312
await addMessagesAndDiffs(
295313
chatId,
296314
path,
297-
[
298-
{ role: "ai", content: cleanMessage },
299-
],
315+
[{ role: "ai", content: cleanMessage }],
300316
diffRaw,
301317
context
302318
);

app/lib/docs.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import yaml from "js-yaml";
55
import { isCloudflare } from "./detectCloudflare";
66
import { notFound } from "next/navigation";
77
import crypto from "node:crypto";
8+
import { z } from "zod";
89

910
/*
1011
Branded Types
@@ -18,33 +19,61 @@ type Brand<K, T> = K & { readonly __brand: T };
1819
export type LangId = Brand<string, "LangId">;
1920
export type LangName = Brand<string, "LangName">;
2021
export type PageSlug = Brand<string, "PageSlug">;
22+
export type SectionId = Brand<string, "SectionId">;
23+
24+
export const PagePathSchema = z.object({
25+
lang: z.string().transform((s) => s as LangId),
26+
page: z.string().transform((s) => s as PageSlug),
27+
});
2128
export interface PagePath {
2229
lang: LangId;
2330
page: PageSlug;
2431
}
25-
export type SectionId = Brand<string, "SectionId">;
2632

27-
export interface MarkdownSection {
33+
export const MarkdownSectionSchema = z.object({
2834
/**
2935
* セクションのmdファイル名
3036
*/
31-
file: string;
37+
file: z.string(),
3238
/**
3339
* frontmatterに書くセクションid
3440
* (データベース上の sectionId)
3541
*/
36-
id: SectionId;
37-
level: number;
38-
title: string;
42+
id: z.string().transform((s) => s as SectionId),
43+
level: z.number(),
44+
title: z.string(),
3945
/**
4046
* frontmatterを除く、見出しも含めたもとのmarkdownの内容
4147
*/
42-
rawContent: string;
48+
rawContent: z.string(),
4349
/**
4450
* rawContentのmd5ハッシュのbase64エンコード
4551
*/
46-
md5: string;
47-
}
52+
md5: z.string(),
53+
});
54+
export type MarkdownSection = z.output<typeof MarkdownSectionSchema>;
55+
56+
export const ReplacedRangeSchema = z.object({
57+
start: z.number(),
58+
end: z.number(),
59+
id: z.string(),
60+
});
61+
export type ReplacedRange = z.output<typeof ReplacedRangeSchema>;
62+
63+
export const DynamicMarkdownSectionSchema = MarkdownSectionSchema.extend({
64+
/**
65+
* ユーザーが今そのセクションを読んでいるかどうか
66+
*/
67+
inView: z.boolean(),
68+
/**
69+
* チャットの会話を元にAIが書き換えた後の内容
70+
*/
71+
replacedContent: z.string(),
72+
replacedRange: z.array(ReplacedRangeSchema),
73+
});
74+
export type DynamicMarkdownSection = z.output<
75+
typeof DynamicMarkdownSectionSchema
76+
>;
4877

4978
/**
5079
* 各言語のindex.ymlから読み込んだデータにid,index等を追加したデータ型

app/markdown/markdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import remarkCjkFriendly from "remark-cjk-friendly";
55
import {
66
MultiHighlightTag,
77
remarkMultiHighlight,
8-
ReplacedRange,
98
} from "./multiHighlight";
109
import { Heading } from "./heading";
1110
import { AutoCodeBlock } from "./codeBlock";
11+
import { ReplacedRange } from "@/lib/docs";
1212

1313
export function StyledMarkdown(props: {
1414
content: string;

app/markdown/multiHighlight.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,8 @@ import { ExtraProps } from "react-markdown";
66
import clsx from "clsx";
77
import { useChatId } from "@/(docs)/chatAreaState";
88
import Link from "next/link";
9+
import type { ReplacedRange } from "@/lib/docs";
910

10-
export interface ReplacedRange {
11-
start: number;
12-
end: number;
13-
id: string;
14-
}
1511
export const remarkMultiHighlight: Plugin<[ReplacedRange[]], Root> = (
1612
replacedRange?: ReplacedRange[]
1713
) => {

app/sidebar.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22
import Link from "next/link";
33
import { usePathname } from "next/navigation";
4-
import { LangId, LanguageEntry, PagePath, PageSlug } from "@/lib/docs";
4+
import { DynamicMarkdownSection, LangId, LanguageEntry, PagePath, PageSlug } from "@/lib/docs";
55
import { AccountMenu } from "./accountMenu";
66
import { ThemeToggle } from "./themeToggle";
77
import {
@@ -15,7 +15,6 @@ import {
1515
import clsx from "clsx";
1616
import { LanguageIcon } from "@/terminal/icons";
1717
import { RuntimeLang } from "@my-code/runtime/languages";
18-
import { DynamicMarkdownSection } from "./(docs)/@docs/[lang]/[pageId]/pageContent";
1918

2019
export interface ISidebarMdContext {
2120
loadedPath: PagePath | null;

package-lock.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)