Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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バリデーションを実践してみましょう:

  1. 完全なRPC API

    • ユーザー管理システム
    • 投稿管理システム
    • コメント機能
  2. 高度なバリデーション

    • 条件付きバリデーション
    • カスタムバリデーター
    • リアルタイム検証
  3. クライアント統合

    • React Hooks統合
    • エラーハンドリング
    • 型安全性の確保

ポイント

  • RPCパターン:関数呼び出しのような直感的なAPI設計
  • zodバリデーション:スキーマファーストの型安全バリデーション
  • エラーハンドリング:構造化されたエラー処理
  • リアルタイム検証:ユーザー体験を向上させる即座のフィードバック
  • 型安全性:エンドツーエンドの型安全な通信

参考文献