総合演習 — コア機能
第2フェーズでは, Pinia, Composable, Server API, Middleware, Pluginsを学びました。今こそそれらをすべて結びつける時です。Charlieの要件は:カートはAPIからStore, ページまで完全に統合されること。Bobの管理画面にはアクセス制御があること。Aliceのショッピング体験はシームレスで途切れないこと。
1. 学ぶ内容
- 完全なカートCRUD APIの実装
- PiniaカートStoreとSSR状態同期の統合
useCartとuseProductFilterのカスタムComposable開発authグローバルミドルウェアで注文ページを保護- エンドツーエンド統合テスト:API → Composable → Store → ページレンダリング
2. チームのある本当の話
(1) 課題:モジュールは独立して動くが統合できない
Aliceのカートページは商品を表示しますが, リフレッシュするとデータが消えます。BobのAPIは書かれているのに, フロントエンドが呼び出していません。ミドルウェアは書かれているのに統合されていません。各モジュールは単体では動作しますが, 組み合わせるとシステムが壊れます。
(2) エンドツーエンド統合テストの解決策
API → Composable → Store → ページのデータチェーンを完全に統合し, 各段階でデータフローを明確にします。
(3) メリット:完全なショッピング体験
Aliceはカートに商品を追加 → リフレッシュ後も商品が保持される → 注文ページに進む → 管理画面はアクセス制御で保護される。全プロセスがシームレスです。
3. 第2フェーズコア機能モジュールの連携アーキテクチャ
graph TB
A[MegaShop 第2フェーズ] --> B[Server API層]
A --> C[Composable層]
A --> D[状態層 - Pinia]
A --> E[ミドルウェア層]
A --> F[プラグイン層]
B --> B1[/api/products CRUD]
B --> B2[/api/cart CRUD]
B --> B3[/api/auth ログイン/登録]
C --> C1[useCart → Store + API]
C --> C2[useProductFilter → API]
C --> C3[usePriceFormat → 純粋関数]
D --> D1[cartStore → items/total]
D --> D2[userStore → auth/profile]
E --> E1[auth.global.ts → ログインチェック]
E --> E2[admin.ts → ロールチェック]
F --> F1[logger → デバッグ]
F --> F2[stripe.client → 決済]
C1 --> D1
C1 --> B2
C2 --> B1
(1) 完全なデータフロー
| 操作 | データフロー | 関連モジュール |
|---|---|---|
| 商品閲覧 | API → useFetch → ページ | Server API |
| カートに追加 | API → useCart → cartStore → ページ | API + Composable + Store |
| カート表示 | cartStore → ページ | Store |
| 注文 | cartStore → useCheckout → Stripe → API | Store + Composable + Plugin + API |
| 管理画面 | authミドルウェア → adminミドルウェア → ページ | Middleware |
4. 完全なカートAPI
(1) ▶ サンプル:完全なカートAPI
TYPESCRIPT
// server/api/cart/index.get.ts
export default defineEventHandler((event) => {
const sessionId = getCookie(event, 'session-id') || 'default'
const cart = mockCarts.find(c => c.sessionId === sessionId)
return cart || { sessionId, items: [], total: 0 }
})
出力:
TEXT
// 実行成功
TYPESCRIPT
// server/api/cart/add.post.ts
export default defineEventHandler(async (event) => {
const sessionId = getCookie(event, 'session-id') || 'default'
const { productId, quantity = 1 } = await readBody(event)
const product = mockProducts.find(p => p.id === productId)
if (!product) throw createError({ statusCode: 404, message: 'Product not found' })
let cart = mockCarts.find(c => c.sessionId === sessionId)
if (!cart) {
cart = { sessionId, items: [], total: 0 }
mockCarts.push(cart)
}
const existing = cart.items.find(i => i.productId === productId)
if (existing) {
existing.quantity += quantity
} else {
cart.items.push({ productId, name: product.name, price: product.price, quantity, image: product.image })
}
cart.total = cart.items.reduce((s, i) => s + i.price * i.quantity, 0)
setCookie(event, 'session-id', sessionId, { httpOnly: true, maxAge: 86400 * 30 })
return cart
})
TYPESCRIPT
// server/api/cart/remove.delete.ts
export default defineEventHandler(async (event) => {
const sessionId = getCookie(event, 'session-id') || 'default'
const { productId } = await readBody(event)
const cart = mockCarts.find(c => c.sessionId === sessionId)
if (!cart) throw createError({ statusCode: 404, message: 'Cart not found' })
cart.items = cart.items.filter(i => i.productId !== productId)
cart.total = cart.items.reduce((s, i) => s + i.price * i.quantity, 0)
return cart
})
5. Pinia Store + Composable統合
(1) ▶ サンプル:強化版cartStore
TYPESCRIPT
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const isLoading = ref(false)
const totalItems = computed(() => items.value.reduce((s, i) => s + i.quantity, 0))
const totalPrice = computed(() => items.value.reduce((s, i) => s + i.price * i.quantity, 0))
const formattedTotal = computed(() =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(totalPrice.value)
)
async function fetchCart() {
isLoading.value = true
try {
const cart = await $fetch('/api/cart')
items.value = cart.items || []
} finally {
isLoading.value = false
}
}
async function addItem(productId: number, quantity = 1) {
const cart = await $fetch('/api/cart/add', {
method: 'POST', body: { productId, quantity }
})
items.value = cart.items
}
async function removeItem(productId: number) {
const cart = await $fetch('/api/cart/remove', {
method: 'DELETE', body: { productId }
})
items.value = cart.items
}
function clearCart() {
items.value = []
}
return { items, isLoading, totalItems, totalPrice, formattedTotal, fetchCart, addItem, removeItem, clearCart }
})
interface CartItem { productId: number; name: string; price: number; quantity: number; image: string }
出力:
TEXT
// 実行成功
(2) ▶ サンプル:useCart Composable
TYPESCRIPT
// composables/useCart.ts
export function useCart() {
const cartStore = useCartStore()
const { $logger } = useNuxtApp()
const { notify } = useNotification()
async function addToCart(productId: number, productName: string) {
try {
await cartStore.addItem(productId)
notify(`${productName} added to cart`, 'success')
$logger.info('Item added to cart', { productId })
} catch (err) {
notify('Failed to add item', 'error')
$logger.error('Add to cart failed', { productId, err })
}
}
async function removeFromCart(productId: number) {
try {
await cartStore.removeItem(productId)
notify('Item removed from cart', 'info')
} catch (err) {
notify('Failed to remove item', 'error')
}
}
return {
items: computed(() => cartStore.items),
totalItems: computed(() => cartStore.totalItems),
totalPrice: computed(() => cartStore.totalPrice),
formattedTotal: computed(() => cartStore.formattedTotal),
isLoading: computed(() => cartStore.isLoading),
addToCart,
removeFromCart
}
}
出力:
TEXT
// 実行成功
6. 権限ミドルウェア設定
(1) ▶ サンプル:完全なミドルウェアアーキテクチャ
TYPESCRIPT
// middleware/01-auth.global.ts
export default defineNuxtRouteMiddleware((to) => {
const publicPaths = ['/', '/products', '/about', '/login', '/register']
if (publicPaths.some(p => to.path === p || to.path.startsWith('/products'))) return
const token = useCookie('auth-token')
if (!token.value) return navigateTo(`/login?redirect=${encodeURIComponent(to.path)}`)
})
出力:
TEXT
// 実行成功
TYPESCRIPT
// middleware/admin.ts
export default defineNuxtRouteMiddleware((to) => {
const userCookie = useCookie('user-data')
if (!userCookie.value) return navigateTo('/login')
if ((userCookie.value as any).role !== 'admin') {
throw createError({ statusCode: 403, message: 'Admin access required' })
}
})
7. エンドツーエンド統合テストページ
(1) ▶ サンプル:商品詳細ページ (完全データフロー)
VUE
<!-- pages/products/[id].vue -->
<template>
<div v-if="product" class="product-detail">
<img :src="product.image" :alt="product.name" />
<div class="info">
<h1>{{ product.name }}</h1>
<p class="price">{{ formattedPrice }}</p>
<p>{{ product.reviewCount }} reviews</p>
<button @click="handleAddToCart" :disabled="adding">
{{ adding ? 'Adding...' : 'Add to Cart' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const { data: product } = await useFetch(`/api/products/${route.params.id}`)
const { formatted } = usePriceFormat(computed(() => product.value?.price || 0))
const formattedPrice = formatted
const { addToCart } = useCart()
const adding = ref(false)
async function handleAddToCart() {
if (!product.value) return
adding.value = true
await addToCart(product.value.id, product.value.name)
adding.value = false
}
</script>
出力:
TEXT
// 実行成功
8. 統合テストチェックリスト
| テスト項目 | 検証方法 | 期待結果 |
|---|---|---|
| APIが利用可能 | curl /api/products | 商品JSONが返される |
| Store状態共有 | ホームページで追加 → 詳細ページで数量確認 | 数量が一致 |
| Composable呼び出し | 「カートに追加」をクリック | 成功:追加 + 通知 |
| ミドルウェア保護 | 未認証で/adminにアクセス | /loginにリダイレクト |
| プラグイン実行 | コンソールを確認 | Logger出力あり |
| SSRデータハイドレーション | ソースコードを表示 | HTMLに商品データが含まれる |
| Cookie永続化 | ページをリフレッシュ | カートデータが保持される |
❓ よくある質問
Q StoreとAPIのデータが同期しない場合はどうすればよいですか?
A Storeが
addItemでAPIを呼び出した後, 戻り値を使ってitems配列を更新し, Storeとバックエンドの同期を確保してください。APIを呼び出さずにStoreだけを変更しないでください。Q ComposableでStoreと$fetchの両方を使うのは冗長ですか?
A いいえ。Storeはクライアント側の状態を管理し, $fetchはバックエンドと同期します。useCartは両方をラップしているため, 呼び出し側は詳細を気にする必要がありません。
Q ページミドルウェアとAPIミドルウェアの両方が認証を処理すると競合しませんか?
A いいえ。ページミドルウェアはルート遷移を保護し (フロントエンド), サーバーミドルウェアはAPIリクエストを保護します (バックエンド)。両者は補完関係にあり, どちらも必要です。
Q エンドツーエンドのデータフローをデバッグするにはどうすればよいですか?
A Nuxt DevToolsのPiniaパネルでStore状態を確認し, NetworkパネルでAPIリクエストを確認し, コンソールでlogger出力を確認します。APIレスポンス → Store更新 → ページレンダリングの順に段階的にトラブルシューティングしてください。
Q SSR時にカートデータはどのように初期化されますか?
A SSR時, CookieからセッションIDを取得し, APIがセッションIDに基づいてカートを返します。クライアント側のハイドレーション完了後, StoreはAPIペイロードからデータを取得します。
Q 第2フェーズのモックデータはいつ本物のデータベースに置き換えられますか?
A 第4フェーズのレッスン20で, Prismaに置き換えます。APIインターフェース形式はすでに設計済みで, server APIの内部実装を変更するだけです。
📖 まとめ
- 完全なカートデータフロー:API → useCart → cartStore → ページ。各段階でデータが一貫
- Pinia StoreはAPI呼び出し後の戻り値で状態を更新し, クライアントとサーバーの同期を確保
useCartComposableがStore, API, 通知をラップ。ページはaddToCartを呼ぶだけ- グローバルauth + adminミドルウェアで包括的なルート認可ガードを実装
- エンドツーエンド統合テスト:API, Store, Composable, Middleware, Pluginの各コンポーネントを段階的に検証
📝 練習問題
- 基本問題 (難易度:⭐):カートAPIとcartStoreの統合を完了し, 商品詳細ページで「カートに追加」ボタンをクリックした際にカート数が更新されるようにしてください。
- 応用問題 (難易度:⭐⭐):完全なミドルウェアシステムを実装:未認証ユーザーが/checkoutにアクセスしたらログインページにリダイレクト, 一般ユーザーが/adminにアクセスしたら403エラーを返す。
- チャレンジ (難易度:⭐⭐⭐):エンドツーエンドのショッピングフローを実装:商品閲覧 → カートに追加 → カート表示 → 注文 (Stripeプラグインで決済をシミュレート)。全プロセスでデータの一貫性を確保してください。
---|



