Skip to content

Commit 2d6a4d9

Browse files
Copilotna-trium-144
andcommitted
feat: add Zod validation to server actions and API chat route
- Replace `interface MarkdownSection`, `interface PagePath` in docs.ts with Zod schemas + `z.output<typeof schema>` types - Add `ReplacedRangeSchema` and `DynamicMarkdownSectionSchema` to docs.ts, moving the type definitions out of the client-only pageContent.tsx - Remove `interface ReplacedRange` from multiHighlight.tsx; re-export `ReplacedRange` type from @/lib/docs - Remove `interface DynamicMarkdownSection` from pageContent.tsx; re-export `DynamicMarkdownSection` type from @/lib/docs - Replace `ReplOutputType` union, `ReplOutput`, `UpdatedFile`, `ReplCommand` interfaces in packages/runtime/src/interface.ts with Zod schemas + `z.output<typeof schema>` types; add zod dependency to runtime package - Replace `type ChatParams` in route.ts with `ChatParamsSchema` + Zod validation of POST request body (returns 400 on invalid input) - Add `z.string().uuid()` validation to deleteChat and getRedirectFromChat server action parameters Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com>
1 parent 5469337 commit 2d6a4d9

File tree

9 files changed

+114
-69
lines changed

9 files changed

+114
-69
lines changed

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

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,19 @@ 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-
}
22+
export type { DynamicMarkdownSection };
3623

3724
interface PageContentProps {
3825
splitMdContent: MarkdownSection[];

app/actions/deleteChat.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
"use server";
22

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

6+
const chatIdSchema = z.string().uuid();
7+
58
export async function deleteChatAction(chatId: string) {
9+
const parsed = chatIdSchema.safeParse(chatId);
10+
if (!parsed.success) {
11+
throw new Error(parsed.error.issues.map((e) => e.message).join(", "));
12+
}
613
const ctx = await initContext();
7-
await deleteChat(chatId, ctx);
14+
await deleteChat(parsed.data, ctx);
815
}

app/actions/getRedirectFromChat.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
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

9+
const chatIdSchema = z.string().uuid();
10+
811
export async function getRedirectFromChat(chatId: string): Promise<string> {
12+
const parsed = chatIdSchema.safeParse(chatId);
13+
if (!parsed.success) {
14+
throw new Error(parsed.error.issues.map((e) => e.message).join(", "));
15+
}
916
const { drizzle, userId } = await initContext();
1017
if (!userId) {
1118
throw new Error("Not authenticated");
1219
}
1320

1421
const chatData = (await drizzle.query.chat.findFirst({
15-
where: and(eq(chat.chatId, chatId), eq(chat.userId, userId)),
22+
where: and(eq(chat.chatId, parsed.data), eq(chat.userId, userId)),
1623
with: {
1724
section: true,
1825
},

app/api/chat/route.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,26 @@ 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 { ReplCommandSchema, ReplOutputSchema } from "@my-code/runtime/interface";
17+
import { z } from "zod";
18+
19+
const ChatParamsSchema = z.object({
20+
path: PagePathSchema,
21+
userQuestion: z.string().min(1),
22+
sectionContent: z.array(DynamicMarkdownSectionSchema),
23+
replOutputs: z.record(z.string(), z.array(ReplCommandSchema)),
24+
files: z.record(z.string(), z.string()),
25+
execResults: z.record(z.string(), z.array(ReplOutputSchema)),
26+
});
1227

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-
};
28+
type ChatParams = z.output<typeof ChatParamsSchema>;
2129

2230
export type ChatStreamEvent =
2331
| { type: "chat"; chatId: string; sectionId: string }
@@ -31,7 +39,14 @@ export async function POST(request: NextRequest) {
3139
return new Response("Unauthorized", { status: 401 });
3240
}
3341

34-
const params = (await request.json()) as ChatParams;
42+
const parseResult = ChatParamsSchema.safeParse(await request.json());
43+
if (!parseResult.success) {
44+
return new Response(
45+
parseResult.error.issues.map((e) => e.message).join(", "),
46+
{ status: 400 }
47+
);
48+
}
49+
const params: ChatParams = parseResult.data;
3550
const { path, userQuestion, sectionContent, replOutputs, files, execResults } =
3651
params;
3752

app/lib/docs.ts

Lines changed: 36 additions & 12 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,56 @@ 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">;
21-
export interface PagePath {
22-
lang: LangId;
23-
page: PageSlug;
24-
}
2522
export type SectionId = Brand<string, "SectionId">;
2623

27-
export interface MarkdownSection {
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+
});
28+
export type PagePath = z.output<typeof PagePathSchema>;
29+
30+
export const MarkdownSectionSchema = z.object({
2831
/**
2932
* セクションのmdファイル名
3033
*/
31-
file: string;
34+
file: z.string(),
3235
/**
3336
* frontmatterに書くセクションid
3437
* (データベース上の sectionId)
3538
*/
36-
id: SectionId;
37-
level: number;
38-
title: string;
39+
id: z.string().transform((s) => s as SectionId),
40+
level: z.number(),
41+
title: z.string(),
3942
/**
4043
* frontmatterを除く、見出しも含めたもとのmarkdownの内容
4144
*/
42-
rawContent: string;
45+
rawContent: z.string(),
4346
/**
4447
* rawContentのmd5ハッシュのbase64エンコード
4548
*/
46-
md5: string;
47-
}
49+
md5: z.string(),
50+
});
51+
export type MarkdownSection = z.output<typeof MarkdownSectionSchema>;
52+
53+
export const ReplacedRangeSchema = z.object({
54+
start: z.number(),
55+
end: z.number(),
56+
id: z.string(),
57+
});
58+
export type ReplacedRange = z.output<typeof ReplacedRangeSchema>;
59+
60+
export const DynamicMarkdownSectionSchema = MarkdownSectionSchema.extend({
61+
/**
62+
* ユーザーが今そのセクションを読んでいるかどうか
63+
*/
64+
inView: z.boolean(),
65+
/**
66+
* チャットの会話を元にAIが書き換えた後の内容
67+
*/
68+
replacedContent: z.string(),
69+
replacedRange: z.array(ReplacedRangeSchema),
70+
});
71+
export type DynamicMarkdownSection = z.output<typeof DynamicMarkdownSectionSchema>;
4872

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

app/markdown/multiHighlight.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,9 @@ 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-
}
11+
export type { ReplacedRange };
1512
export const remarkMultiHighlight: Plugin<[ReplacedRange[]], Root> = (
1613
replacedRange?: ReplacedRange[]
1714
) => {

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.

packages/runtime/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"react": "^19",
2121
"react-dom": "^19",
2222
"swr": "^2",
23-
"typescript": "^5"
23+
"typescript": "^5",
24+
"zod": "^4.0.17"
2425
},
2526
"devDependencies": {
2627
"@testing-library/react": "^16.3.2",

packages/runtime/src/interface.ts

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { MutexInterface } from "async-mutex";
2+
import { z } from "zod";
23

34
/**
45
* 各言語の実行環境のインタフェース
@@ -148,28 +149,33 @@ export interface RuntimeInfo {
148149
version?: string;
149150
}
150151

151-
export type ReplOutputType =
152-
| "stdout"
153-
| "stderr"
154-
| "error"
155-
| "return"
156-
| "trace"
157-
| "system";
158-
export interface ReplOutput {
159-
type: ReplOutputType; // 出力の種類
160-
message: string; // 出力メッセージ
161-
}
162-
export interface UpdatedFile {
163-
type: "file";
164-
filename: string;
165-
content: string;
166-
}
152+
export const ReplOutputTypeSchema = z.enum([
153+
"stdout",
154+
"stderr",
155+
"error",
156+
"return",
157+
"trace",
158+
"system",
159+
]);
160+
export type ReplOutputType = z.output<typeof ReplOutputTypeSchema>;
161+
export const ReplOutputSchema = z.object({
162+
type: ReplOutputTypeSchema, // 出力の種類
163+
message: z.string(), // 出力メッセージ
164+
});
165+
export type ReplOutput = z.output<typeof ReplOutputSchema>;
166+
export const UpdatedFileSchema = z.object({
167+
type: z.literal("file"),
168+
filename: z.string(),
169+
content: z.string(),
170+
});
171+
export type UpdatedFile = z.output<typeof UpdatedFileSchema>;
167172

168-
export interface ReplCommand {
169-
command: string;
170-
output: ReplOutput[];
171-
commandId?: string; // Optional for backward compatibility
172-
}
173+
export const ReplCommandSchema = z.object({
174+
command: z.string(),
175+
output: z.array(ReplOutputSchema),
176+
commandId: z.string().optional(), // Optional for backward compatibility
177+
});
178+
export type ReplCommand = z.output<typeof ReplCommandSchema>;
173179
export type SyntaxStatus = "complete" | "incomplete" | "invalid"; // 構文チェックの結果
174180

175181
export const emptyMutex: MutexInterface = {

0 commit comments

Comments
 (0)