Skip to content

Commit c7abff8

Browse files
arkjunGemini
andcommitted
refactor: introduce Hono RPC and Zod validation
feat(api): add Zod validation for subscriptions feat(api): add db middleware refactor(web): use Hono RPC client in AuthProvider chore: add missing types dependencies Co-authored-by: Gemini <noreply@google.com>
1 parent b6f4b05 commit c7abff8

File tree

8 files changed

+116
-47
lines changed

8 files changed

+116
-47
lines changed

apps/api/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@
1919
"db:seed:prod": "tsx scripts/seed.ts > scripts/seed.sql && wrangler d1 execute sublistme-db --file=scripts/seed.sql"
2020
},
2121
"dependencies": {
22+
"@hono/zod-validator": "^0.7.6",
2223
"@lucia-auth/adapter-sqlite": "^3.0.2",
2324
"@sublistme/db": "workspace:*",
2425
"arctic": "^3.7.0",
2526
"drizzle-orm": "^0.38.3",
2627
"hono": "^4.11.4",
2728
"lucia": "^3.2.2",
28-
"oslo": "^1.2.1"
29+
"oslo": "^1.2.1",
30+
"zod": "^4.3.6"
2931
},
3032
"devDependencies": {
3133
"@cloudflare/vitest-pool-workers": "^0.11.1",

apps/api/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { auth } from './routes/auth';
66
import { subscriptions } from './routes/subscriptions';
77
import { users } from './routes/users';
88

9+
import { dbMiddleware, type DbVariables } from './middleware/db';
10+
911
export type Env = {
1012
DB: D1Database;
1113
GOOGLE_CLIENT_ID: string;
@@ -17,7 +19,7 @@ export type Env = {
1719
type Variables = {
1820
user: User | null;
1921
session: Session | null;
20-
};
22+
} & DbVariables;
2123

2224
const app = new Hono<{ Bindings: Env; Variables: Variables }>();
2325

@@ -34,6 +36,7 @@ app.use(
3436
}),
3537
);
3638
app.use('/*', sessionMiddleware);
39+
app.use('/*', dbMiddleware);
3740

3841
// Routes
3942
app.route('/auth', auth);

apps/api/src/middleware/db.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { drizzle, type DrizzleD1Database } from 'drizzle-orm/d1';
2+
import { createMiddleware } from 'hono/factory';
3+
import type { Env } from '../index';
4+
5+
export type DbVariables = {
6+
db: DrizzleD1Database;
7+
};
8+
9+
export const dbMiddleware = createMiddleware<{
10+
Bindings: Env;
11+
Variables: DbVariables;
12+
}>(async (c, next) => {
13+
const db = drizzle(c.env.DB);
14+
c.set('db', db);
15+
await next();
16+
});

apps/api/src/routes/subscriptions.ts

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,40 @@
1+
import { zValidator } from '@hono/zod-validator';
12
import { subscriptions as subscriptionsTable } from '@sublistme/db/schema';
2-
import type { SubscriptionInput } from '@sublistme/db/types';
33
import { and, eq } from 'drizzle-orm';
4-
import { drizzle } from 'drizzle-orm/d1';
54
import { Hono } from 'hono';
65
import type { Session, User } from 'lucia';
6+
import { z } from 'zod';
77
import type { Env } from '../index';
88
import { requireAuth } from '../middleware/auth';
9+
import type { DbVariables } from '../middleware/db';
910

1011
type Variables = {
1112
user: User | null;
1213
session: Session | null;
13-
};
14+
} & DbVariables;
15+
16+
const subscriptionSchema = z.object({
17+
name: z.string().min(1),
18+
description: z.string().optional(),
19+
price: z.number().nonnegative(),
20+
originalPrice: z.number().nonnegative().optional(),
21+
currency: z.enum(['KRW', 'USD', 'JPY', 'EUR']).default('KRW'),
22+
billingCycle: z.enum(['monthly', 'yearly', 'weekly', 'quarterly']).default('monthly'),
23+
nextBillingDate: z.string().optional(),
24+
startDate: z.string().optional(),
25+
country: z.string().default('KR'),
26+
category: z.enum(['ott', 'music', 'gaming', 'shopping', 'productivity', 'cloud', 'news', 'fitness', 'education', 'finance', 'food', 'security', 'other']).optional(),
27+
url: z.string().optional(),
28+
logoUrl: z.string().optional(),
29+
memo: z.string().optional(),
30+
isActive: z.boolean().default(true),
31+
});
32+
33+
const updateSubscriptionSchema = subscriptionSchema.partial();
34+
35+
const bulkSubscriptionSchema = z.object({
36+
subscriptions: z.array(subscriptionSchema),
37+
});
1438

1539
export const subscriptions = new Hono<{ Bindings: Env; Variables: Variables }>()
1640
// 모든 구독 라우트에 인증 필수
@@ -19,7 +43,7 @@ export const subscriptions = new Hono<{ Bindings: Env; Variables: Variables }>()
1943
// 구독 목록 조회 (사용자별)
2044
.get('/', async (c) => {
2145
const user = c.get('user')!;
22-
const db = drizzle(c.env.DB);
46+
const db = c.get('db');
2347
const result = await db
2448
.select()
2549
.from(subscriptionsTable)
@@ -30,7 +54,7 @@ export const subscriptions = new Hono<{ Bindings: Env; Variables: Variables }>()
3054
// 구독 상세 조회 (사용자별)
3155
.get('/:id', async (c) => {
3256
const user = c.get('user')!;
33-
const db = drizzle(c.env.DB);
57+
const db = c.get('db');
3458
const id = c.req.param('id');
3559
const result = await db
3660
.select()
@@ -49,10 +73,10 @@ export const subscriptions = new Hono<{ Bindings: Env; Variables: Variables }>()
4973
})
5074

5175
// 구독 생성 (사용자 ID 자동 추가)
52-
.post('/', async (c) => {
76+
.post('/', zValidator('json', subscriptionSchema), async (c) => {
5377
const user = c.get('user')!;
54-
const db = drizzle(c.env.DB);
55-
const body = await c.req.json();
78+
const db = c.get('db');
79+
const body = c.req.valid('json');
5680
const result = await db
5781
.insert(subscriptionsTable)
5882
.values({ ...body, userId: user.id })
@@ -61,20 +85,16 @@ export const subscriptions = new Hono<{ Bindings: Env; Variables: Variables }>()
6185
})
6286

6387
// 구독 일괄 생성 (다건)
64-
.post('/bulk', async (c) => {
88+
.post('/bulk', zValidator('json', bulkSubscriptionSchema), async (c) => {
6589
const user = c.get('user')!;
66-
const db = drizzle(c.env.DB);
67-
const body = await c.req.json<{ subscriptions: SubscriptionInput[] }>();
90+
const db = c.get('db');
91+
const { subscriptions } = c.req.valid('json');
6892

69-
if (!body.subscriptions || !Array.isArray(body.subscriptions)) {
70-
return c.json({ error: 'Invalid request body' }, 400);
71-
}
72-
73-
if (body.subscriptions.length === 0) {
93+
if (subscriptions.length === 0) {
7494
return c.json({ error: 'No subscriptions provided' }, 400);
7595
}
7696

77-
const subscriptionsToInsert = body.subscriptions.map((s) => ({
97+
const subscriptionsToInsert = subscriptions.map((s) => ({
7898
...s,
7999
userId: user.id,
80100
}));
@@ -88,11 +108,11 @@ export const subscriptions = new Hono<{ Bindings: Env; Variables: Variables }>()
88108
})
89109

90110
// 구독 수정 (사용자별)
91-
.put('/:id', async (c) => {
111+
.put('/:id', zValidator('json', updateSubscriptionSchema), async (c) => {
92112
const user = c.get('user')!;
93-
const db = drizzle(c.env.DB);
113+
const db = c.get('db');
94114
const id = c.req.param('id');
95-
const body = await c.req.json();
115+
const body = c.req.valid('json');
96116
const result = await db
97117
.update(subscriptionsTable)
98118
.set(body)
@@ -113,7 +133,7 @@ export const subscriptions = new Hono<{ Bindings: Env; Variables: Variables }>()
113133
// 구독 삭제 (사용자별)
114134
.delete('/:id', async (c) => {
115135
const user = c.get('user')!;
116-
const db = drizzle(c.env.DB);
136+
const db = c.get('db');
117137
const id = c.req.param('id');
118138
const result = await db
119139
.delete(subscriptionsTable)

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
"@types/react": "^19.0.2",
3434
"@types/react-dom": "^19.0.2",
3535
"autoprefixer": "^10.4.20",
36+
"drizzle-orm": "^0.38.3",
37+
"lucia": "^3.2.2",
3638
"postcss": "^8.4.49",
3739
"tailwindcss": "^3.4.17",
3840
"typescript": "^5.7.2",

apps/web/src/components/auth/auth-provider.tsx

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
useState,
1010
} from 'react';
1111

12-
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8787';
12+
import { api } from '@/lib/api';
1313

1414
type User = {
1515
id: string;
@@ -44,10 +44,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
4444

4545
const fetchUser = useCallback(async () => {
4646
try {
47-
const res = await fetch(`${API_URL}/auth/me`, {
48-
credentials: 'include',
49-
});
50-
const data: { user: User | null } = await res.json();
47+
const res = await api.auth.me.$get();
48+
const data = await res.json();
5149
setUser(data.user);
5250
} catch (error) {
5351
console.error('Failed to fetch user:', error);
@@ -63,19 +61,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
6361

6462
/* Existing logic */
6563
const login = () => {
66-
window.location.href = `${API_URL}/auth/login/google`;
64+
window.location.href = api.auth.login.google.$url().toString();
6765
};
6866

67+
6968
const loginWithEmail = async (email: string, password: string) => {
70-
const res = await fetch(`${API_URL}/auth/login/email`, {
71-
method: 'POST',
72-
headers: { 'Content-Type': 'application/json' },
73-
body: JSON.stringify({ email, password }),
74-
credentials: 'include',
69+
const res = await api.auth.login.email.$post({
70+
json: { email, password },
7571
});
7672

7773
if (!res.ok) {
78-
const error = (await res.json()) as { error?: string };
74+
const error = await res.json();
7975
throw new Error(error.error || 'Login failed');
8076
}
8177
await fetchUser();
@@ -86,26 +82,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
8682
password: string,
8783
name: string,
8884
) => {
89-
const res = await fetch(`${API_URL}/auth/signup/email`, {
90-
method: 'POST',
91-
headers: { 'Content-Type': 'application/json' },
92-
body: JSON.stringify({ email, password, name }),
93-
credentials: 'include',
85+
const res = await api.auth.signup.email.$post({
86+
json: { email, password, name },
9487
});
9588

9689
if (!res.ok) {
97-
const error = (await res.json()) as { error?: string };
90+
const error = await res.json();
9891
throw new Error(error.error || 'Signup failed');
9992
}
10093
await fetchUser();
10194
};
10295

10396
const logout = async () => {
10497
try {
105-
await fetch(`${API_URL}/auth/logout`, {
106-
method: 'POST',
107-
credentials: 'include',
108-
});
98+
await api.auth.logout.$post();
99+
109100
setUser(null);
110101
} catch (error) {
111102
console.error('Failed to logout:', error);

apps/web/src/lib/api.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import { hc } from 'hono/client';
44

55
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8787';
66

7-
export const api = hc<AppType>(API_URL);
7+
export const api = hc<AppType>(API_URL, {
8+
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
9+
return fetch(input, {
10+
...init,
11+
credentials: 'include',
12+
});
13+
},
14+
});
815

916
export type UserPreferences = {
1017
locale: Locale;

pnpm-lock.yaml

Lines changed: 29 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)