RPC実装とzodバリデーション
HonoXでは、RPC(Remote Procedure Call)パターンとzodバリデーションを組み合わせることで、より型安全で使いやすいAPI設計が可能になります。従来のREST APIとは異なるアプローチで、関数を呼び出すような直感的なAPI利用体験を実現しましょう。
RPC パターンの理解
従来のREST API vs RPC
// REST API パターン
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Hello', content: '...' })
})
const post = await response.json()
// RPC パターン
const post = await api.posts.create({
title: 'Hello',
content: '...'
})
「RPCパターンでは、リモートの関数をローカルの関数のように呼び出せます。」
Hono RPC の基本実装
// app/lib/rpc/server.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
// バリデーションスキーマ
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10).max(10000),
excerpt: z.string().max(500).optional(),
tags: z.array(z.string()).max(10).optional(),
status: z.enum(['draft', 'published']).default('draft')
})
const UpdatePostSchema = CreatePostSchema.partial()
const PostQuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(10),
tag: z.string().optional(),
author: z.string().optional(),
status: z.enum(['draft', 'published']).optional()
})
// RPC API定義
export const postsAPI = new Hono()
// 投稿一覧取得
postsAPI.get(
'/',
zValidator('query', PostQuerySchema),
async (c) => {
const query = c.req.valid('query')
try {
const posts = await PostService.getList(query)
return c.json({
success: true,
data: posts,
pagination: {
page: query.page,
limit: query.limit,
hasNext: posts.length === query.limit
}
})
} catch (error) {
return c.json({
success: false,
error: 'Failed to fetch posts'
}, 500)
}
}
)
// 投稿作成
postsAPI.post(
'/',
zValidator('json', CreatePostSchema),
async (c) => {
const postData = c.req.valid('json')
const user = c.get('user') // 認証ミドルウェアから取得
try {
const post = await PostService.create({
...postData,
authorId: user.id
})
return c.json({
success: true,
data: post
}, 201)
} catch (error) {
return c.json({
success: false,
error: 'Failed to create post'
}, 500)
}
}
)
// 投稿更新
postsAPI.put(
'/:id',
zValidator('json', UpdatePostSchema),
async (c) => {
const id = c.req.param('id')
const updateData = c.req.valid('json')
const user = c.get('user')
try {
const post = await PostService.update(id, updateData, user.id)
if (!post) {
return c.json({
success: false,
error: 'Post not found or unauthorized'
}, 404)
}
return c.json({
success: true,
data: post
})
} catch (error) {
return c.json({
success: false,
error: 'Failed to update post'
}, 500)
}
}
)
// 投稿削除
postsAPI.delete('/:id', async (c) => {
const id = c.req.param('id')
const user = c.get('user')
try {
const success = await PostService.delete(id, user.id)
if (!success) {
return c.json({
success: false,
error: 'Post not found or unauthorized'
}, 404)
}
return c.json({
success: true,
message: 'Post deleted successfully'
})
} catch (error) {
return c.json({
success: false,
error: 'Failed to delete post'
}, 500)
}
})
// 型推論のためのAPIスキーマ
export type PostsAPI = typeof postsAPI
高度なzodバリデーション
複雑なスキーマ定義
// app/lib/schemas/user.ts
import { z } from 'zod'
// カスタムバリデーション関数
const isStrongPassword = (password: string): boolean => {
const hasUpperCase = /[A-Z]/.test(password)
const hasLowerCase = /[a-z]/.test(password)
const hasNumbers = /\d/.test(password)
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password)
return password.length >= 8 &&
hasUpperCase &&
hasLowerCase &&
hasNumbers &&
hasSpecialChar
}
// ユーザー関連スキーマ
export const UserSchemas = {
// ユーザー登録
register: z.object({
email: z.string()
.email('有効なメールアドレスを入力してください')
.max(255, 'メールアドレスが長すぎます'),
password: z.string()
.min(8, 'パスワードは8文字以上である必要があります')
.refine(isStrongPassword, {
message: 'パスワードは大文字、小文字、数字、特殊文字を含む必要があります'
}),
confirmPassword: z.string(),
name: z.string()
.min(1, '名前は必須です')
.max(100, '名前は100文字以内で入力してください')
.regex(/^[^\s].*[^\s]$/, '名前の前後に空白を含めることはできません'),
profile: z.object({
bio: z.string()
.max(500, '自己紹介は500文字以内で入力してください')
.optional(),
website: z.string()
.url('有効なURLを入力してください')
.optional()
.or(z.literal('')),
birthday: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, '日付はYYYY-MM-DD形式で入力してください')
.optional()
.refine((date) => {
if (!date) return true
const birthDate = new Date(date)
const today = new Date()
const age = today.getFullYear() - birthDate.getFullYear()
return age >= 13 && age <= 120
}, {
message: '年齢は13歳以上120歳以下である必要があります'
})
}).optional()
}).refine((data) => data.password === data.confirmPassword, {
message: 'パスワードが一致しません',
path: ['confirmPassword']
}),
// プロフィール更新
updateProfile: z.object({
name: z.string()
.min(1, '名前は必須です')
.max(100, '名前は100文字以内で入力してください')
.optional(),
profile: z.object({
bio: z.string().max(500).optional(),
website: z.string().url().optional().or(z.literal('')),
avatar: z.string().url().optional(),
preferences: z.object({
theme: z.enum(['light', 'dark', 'auto']).default('auto'),
language: z.enum(['ja', 'en']).default('ja'),
notifications: z.object({
email: z.boolean().default(true),
push: z.boolean().default(false),
marketing: z.boolean().default(false)
}).default({})
}).optional()
}).optional()
}),
// ログイン
login: z.object({
email: z.string().email('有効なメールアドレスを入力してください'),
password: z.string().min(1, 'パスワードを入力してください'),
remember: z.boolean().optional().default(false)
})
}
// 型推論
export type RegisterUserRequest = z.infer<typeof UserSchemas.register>
export type UpdateProfileRequest = z.infer<typeof UserSchemas.updateProfile>
export type LoginRequest = z.infer<typeof UserSchemas.login>
動的バリデーション
// app/lib/schemas/dynamic.ts
import { z } from 'zod'
// 条件付きバリデーション
export const createConditionalSchema = (userRole: 'admin' | 'user') => {
const baseSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10).max(10000),
status: z.enum(['draft', 'published'])
})
if (userRole === 'admin') {
// 管理者は追加フィールドを設定可能
return baseSchema.extend({
featured: z.boolean().optional(),
priority: z.number().min(0).max(10).optional(),
scheduledAt: z.string().datetime().optional(),
seoSettings: z.object({
metaTitle: z.string().max(60).optional(),
metaDescription: z.string().max(160).optional(),
keywords: z.array(z.string()).max(10).optional()
}).optional()
})
}
return baseSchema
}
// 配列の長さに応じた動的バリデーション
export const createBatchSchema = <T extends z.ZodType>(
itemSchema: T,
maxItems: number = 100
) => {
return z.array(itemSchema)
.min(1, '少なくとも1つの項目が必要です')
.max(maxItems, `最大${maxItems}件まで処理できます`)
.refine(
(items) => {
// 重複チェック(IDベース)
const ids = items.map((item: any) => item.id).filter(Boolean)
return new Set(ids).size === ids.length
},
{ message: '重複するIDが含まれています' }
)
}
RPCクライアントの実装
型安全なクライアント生成
// app/lib/rpc/client.ts
import type { PostsAPI } from './server'
// RPC応答の型定義
interface RPCSuccess<T> {
success: true
data: T
}
interface RPCError {
success: false
error: string
details?: any[]
}
type RPCResponse<T> = RPCSuccess<T> | RPCError
// APIクライアント基底クラス
abstract class BaseRPCClient {
protected async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const response = await fetch(`/api/rpc${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result = await response.json()
if (!result.success) {
throw new RPCError(result.error, result.details)
}
return result.data
}
}
export class RPCError extends Error {
constructor(
message: string,
public details?: any[]
) {
super(message)
this.name = 'RPCError'
}
}
// Posts用RPCクライアント
export class PostsRPCClient extends BaseRPCClient {
async getList(params: {
page?: number
limit?: number
tag?: string
author?: string
status?: 'draft' | 'published'
} = {}) {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
searchParams.set(key, String(value))
}
})
const endpoint = `/posts?${searchParams.toString()}`
return this.request<Post[]>(endpoint)
}
async getById(id: string) {
return this.request<Post>(`/posts/${id}`)
}
async create(data: CreatePostRequest) {
return this.request<Post>('/posts', {
method: 'POST',
body: JSON.stringify(data)
})
}
async update(id: string, data: UpdatePostRequest) {
return this.request<Post>(`/posts/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
async delete(id: string) {
return this.request<{ message: string }>(`/posts/${id}`, {
method: 'DELETE'
})
}
}
// グローバルクライアントインスタンス
export const rpcClient = {
posts: new PostsRPCClient()
}
React Hooks統合
// app/lib/hooks/useRPC.ts
import { useState, useEffect, useCallback } from 'react'
import { rpcClient, RPCError } from '../rpc/client'
// 汎用RPC Hook
export function useRPC<T, P extends any[]>(
rpcCall: (...args: P) => Promise<T>,
deps: React.DependencyList = []
) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const execute = useCallback(async (...args: P) => {
try {
setLoading(true)
setError(null)
const result = await rpcCall(...args)
setData(result)
return result
} catch (err) {
const errorMessage = err instanceof RPCError
? err.message
: 'An unexpected error occurred'
setError(errorMessage)
throw err
} finally {
setLoading(false)
}
}, deps)
return {
data,
loading,
error,
execute,
refetch: () => execute(...([] as any))
}
}
// Posts専用フック
export function usePostsList(params: Parameters<typeof rpcClient.posts.getList>[0] = {}) {
const { data, loading, error, execute, refetch } = useRPC(
rpcClient.posts.getList,
[params.page, params.limit, params.tag, params.author, params.status]
)
useEffect(() => {
execute(params)
}, [execute, params])
return {
posts: data || [],
loading,
error,
refetch
}
}
export function usePost(id: string) {
const { data, loading, error, execute } = useRPC(
rpcClient.posts.getById,
[id]
)
useEffect(() => {
if (id) {
execute(id)
}
}, [execute, id])
return {
post: data,
loading,
error
}
}
// ミューテーション専用フック
export function useCreatePost() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const createPost = async (data: CreatePostRequest) => {
try {
setLoading(true)
setError(null)
const result = await rpcClient.posts.create(data)
return result
} catch (err) {
const errorMessage = err instanceof RPCError
? err.message
: 'Failed to create post'
setError(errorMessage)
throw err
} finally {
setLoading(false)
}
}
return {
createPost,
loading,
error
}
}
バリデーションエラーの高度な処理
フォームレベルでのエラー統合
// app/lib/validation/form-handler.ts
import { z } from 'zod'
interface ValidationError {
field: string
message: string
code: string
}
interface ValidationResult<T> {
success: boolean
data?: T
errors?: ValidationError[]
}
export class FormValidator<T extends z.ZodType> {
constructor(private schema: T) {}
validate(data: unknown): ValidationResult<z.infer<T>> {
try {
const validData = this.schema.parse(data)
return {
success: true,
data: validData
}
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
errors: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code
}))
}
}
return {
success: false,
errors: [{
field: 'root',
message: 'Validation failed',
code: 'unknown'
}]
}
}
}
validateField(data: unknown, fieldPath: string): ValidationResult<any> {
try {
const validData = this.schema.parse(data)
return { success: true, data: validData }
} catch (error) {
if (error instanceof z.ZodError) {
const fieldErrors = error.errors.filter(err =>
err.path.join('.') === fieldPath
)
if (fieldErrors.length > 0) {
return {
success: false,
errors: fieldErrors.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code
}))
}
}
}
return { success: true }
}
}
}
リアルタイムバリデーション付きフォーム
// app/islands/forms/ValidatedPostForm.tsx
import { useState, useCallback } from 'react'
import { FormValidator } from '../../lib/validation/form-handler'
import { PostSchemas } from '../../lib/schemas/posts'
import { useCreatePost } from '../../lib/hooks/useRPC'
const postValidator = new FormValidator(PostSchemas.create)
export function ValidatedPostForm() {
const [formData, setFormData] = useState({
title: '',
content: '',
excerpt: '',
tags: [],
status: 'draft' as const
})
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
const [isValidating, setIsValidating] = useState(false)
const { createPost, loading, error } = useCreatePost()
// フィールド単位のバリデーション
const validateField = useCallback((fieldName: string, value: any) => {
setIsValidating(true)
// デバウンス処理
setTimeout(() => {
const testData = { ...formData, [fieldName]: value }
const result = postValidator.validateField(testData, fieldName)
setFieldErrors(prev => ({
...prev,
[fieldName]: result.errors?.[0]?.message || ''
}))
setIsValidating(false)
}, 300)
}, [formData])
const updateField = (fieldName: string, value: any) => {
setFormData(prev => ({ ...prev, [fieldName]: value }))
validateField(fieldName, value)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// フォーム全体のバリデーション
const validation = postValidator.validate(formData)
if (!validation.success) {
const errors: Record<string, string> = {}
validation.errors?.forEach(error => {
errors[error.field] = error.message
})
setFieldErrors(errors)
return
}
try {
const post = await createPost(validation.data)
// 成功時の処理
console.log('Post created:', post)
} catch (err) {
// エラーハンドリング
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700">
タイトル
</label>
<input
type="text"
value={formData.title}
onChange={(e) => updateField('title', e.target.value)}
className={`mt-1 block w-full rounded-md border ${
fieldErrors.title ? 'border-red-500' : 'border-gray-300'
}`}
/>
{fieldErrors.title && (
<p className="mt-1 text-sm text-red-600">{fieldErrors.title}</p>
)}
{isValidating && (
<p className="mt-1 text-sm text-gray-500">検証中...</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
本文
</label>
<textarea
value={formData.content}
onChange={(e) => updateField('content', e.target.value)}
rows={10}
className={`mt-1 block w-full rounded-md border ${
fieldErrors.content ? 'border-red-500' : 'border-gray-300'
}`}
/>
{fieldErrors.content && (
<p className="mt-1 text-sm text-red-600">{fieldErrors.content}</p>
)}
</div>
{error && (
<div className="text-red-600 text-sm">{error}</div>
)}
<button
type="submit"
disabled={loading || isValidating}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '保存中...' : '投稿を作成'}
</button>
</form>
)
}
やってみよう!
RPC実装とzodバリデーションを実践してみましょう:
-
完全なRPC API
- ユーザー管理システム
- 投稿管理システム
- コメント機能
-
高度なバリデーション
- 条件付きバリデーション
- カスタムバリデーター
- リアルタイム検証
-
クライアント統合
- React Hooks統合
- エラーハンドリング
- 型安全性の確保
ポイント
- RPCパターン:関数呼び出しのような直感的なAPI設計
- zodバリデーション:スキーマファーストの型安全バリデーション
- エラーハンドリング:構造化されたエラー処理
- リアルタイム検証:ユーザー体験を向上させる即座のフィードバック
- 型安全性:エンドツーエンドの型安全な通信