Files
nuxt4-deep/app/pages/server-api-demo.vue
hyeonggil fc7d3d14cf Add initial Nuxt 4 project setup with essential configurations
- Created .gitignore to exclude build outputs, logs, and environment files.
- Added nuxt.config.ts for project configuration, enabling Tailwind CSS and Pinia modules.
- Initialized package.json with scripts for development and production, and added necessary dependencies.
- Generated pnpm-lock.yaml for dependency management.
- Created README.md with setup instructions and development guidelines.
- Implemented server API examples in server/api/ directory, demonstrating various use cases.
- Added middleware for logging requests and responses.
- Included example Vue components and pages for server API interaction.
- Established basic project structure for Nuxt 4 application development.
2026-04-08 23:59:29 +09:00

401 lines
18 KiB
Vue

<script setup lang="ts">
/**
* 서버 API 학습 데모 페이지
* 모든 케이스를 브라우저에서 직접 테스트할 수 있습니다.
*/
// ── 케이스 1: 기본 GET ────────────────────────────────────────
// useFetch는 SSR에서 서버 측 호출 → 클라이언트에서 재호출 없이 데이터 hydration
const { data: hello } = await useFetch("/api/01-hello");
// ── 케이스 2: POST + readBody ─────────────────────────────────
const newUserName = ref("");
const newUserEmail = ref("");
const createdUser = ref<{ id: string; name: string; email: string; createdAt: string } | null>(null);
const createUserError = ref<string | null>(null);
async function createUser() {
createdUser.value = null;
createUserError.value = null;
try {
createdUser.value = await $fetch("/api/02-users", {
method: "POST",
body: { name: newUserName.value, email: newUserEmail.value },
});
} catch (err: unknown) {
if (err && typeof err === "object" && "statusMessage" in err) {
createUserError.value = String((err as { statusMessage: string }).statusMessage);
}
}
}
// ── 케이스 3: getQuery 쿼리 파라미터 (반응형) ─────────────────
// query에 ref를 직접 전달 → keyword/page 변경 시 자동 재요청
const keyword = ref("Nuxt");
const page = ref(1);
const { data: searchResult } = await useFetch("/api/03-search", {
query: { keyword, page, limit: 3 },
});
// ── 케이스 4: 동적 라우트 파라미터 ──────────────────────────
// URL을 함수로 감싸야 userId 변경 시 자동 재요청
const userId = ref("1");
const { data: user, error: userError } = await useFetch(
() => `/api/04-users/${userId.value}`
);
// ── 케이스 5: HTTP 메서드 분기 (GET / PUT / DELETE) ───────────
const itemId = ref("1");
const { data: item, error: itemError, refresh: refreshItem } = await useFetch(
() => `/api/05-items/${itemId.value}`
);
// PUT: 아이템 done 토글
async function toggleItem() {
if (!item.value) return;
try {
await $fetch(`/api/05-items/${itemId.value}`, {
method: "PUT",
body: { done: !(item.value as { done: boolean }).done },
});
await refreshItem(); // 변경 후 최신 데이터 재조회
} catch (err: unknown) {
alert(getErrorMessage(err));
}
}
// DELETE: 아이템 삭제 후 id 1로 복귀
async function deleteItem() {
try {
await $fetch(`/api/05-items/${itemId.value}`, { method: "DELETE" });
itemId.value = "1";
await refreshItem();
} catch (err: unknown) {
alert(getErrorMessage(err));
}
}
// ── 케이스 6: createError 에러 처리 ──────────────────────────
// 에러 타입별로 다른 응답 확인
type ErrorType = "notfound" | "forbidden" | "custom" | "random";
const errorType = ref<ErrorType>("notfound");
const errorResult = ref<{ result: string } | null>(null);
const errorResponse = ref<{
statusCode: number;
statusMessage: string;
data?: unknown;
} | null>(null);
async function triggerError() {
errorResult.value = null;
errorResponse.value = null;
try {
const data = await $fetch<{ result: string }>(
`/api/06-error-handling?type=${errorType.value}`
);
errorResult.value = data; // 성공 (random의 경우 50% 확률)
} catch (err: unknown) {
if (err && typeof err === "object" && "statusCode" in err) {
errorResponse.value = err as {
statusCode: number;
statusMessage: string;
data?: unknown;
};
}
}
}
// ── 케이스 7: server/utils auto-import + 헤더 ─────────────────
// Authorization 헤더 유무에 따라 응답이 다름 (가격 포함 여부)
const useAuthToken = ref(false);
const authToken = "my-token-12345";
const { data: products, refresh: refreshProducts } = await useFetch(
"/api/07-products",
{
headers: computed(() =>
useAuthToken.value ? { Authorization: `Bearer ${authToken}` } : {}
),
}
);
// useAuthToken 변경 시 수동 재요청 (headers는 반응형 자동 감지 안 됨)
watch(useAuthToken, () => refreshProducts());
// ── 케이스 8: server/routes/ 웹훅 ────────────────────────────
const webhookSecret = ref("my-secret");
const webhookEvent = ref("push");
const webhookRepo = ref("my-project");
const webhookResult = ref<{ received: boolean } | null>(null);
const webhookError = ref<string | null>(null);
const webhookLoading = ref(false);
async function sendWebhook() {
webhookResult.value = null;
webhookError.value = null;
webhookLoading.value = true;
try {
webhookResult.value = await $fetch("/08-webhook", {
method: "POST",
headers: { "X-Webhook-Secret": webhookSecret.value },
body: { event: webhookEvent.value, repo: webhookRepo.value },
});
} catch (err: unknown) {
webhookError.value = getErrorMessage(err);
} finally {
webhookLoading.value = false;
}
}
// ── 공통 유틸 ─────────────────────────────────────────────────
function getErrorMessage(err: unknown): string {
if (err && typeof err === "object") {
if ("statusMessage" in err)
return `${(err as { statusCode: number }).statusCode}: ${(err as { statusMessage: string }).statusMessage}`;
if ("message" in err) return String((err as { message: string }).message);
}
return "알 수 없는 오류";
}
</script>
<template>
<div class="p-6 space-y-6 max-w-2xl mx-auto font-sans">
<div class="border-b pb-4">
<h1 class="text-2xl font-bold">서버 API 학습 데모</h1>
<p class="text-sm text-gray-500 mt-1">
섹션에서 server/api/ 케이스를 직접 테스트할 있습니다.
</p>
</div>
<!-- 케이스 1: 기본 GET -->
<section class="border rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono bg-blue-100 text-blue-700 px-2 py-0.5 rounded">GET /api/01-hello</span>
<h2 class="font-semibold">케이스 1: 기본 GET</h2>
</div>
<p class="text-xs text-gray-500 mb-2">
<code class="bg-gray-100 px-1 rounded">useFetch</code> SSR 환경에서 서버 측에서 미리 호출되어 클라이언트로 hydration됩니다.
</p>
<pre class="text-sm bg-gray-50 p-3 rounded overflow-auto">{{ hello }}</pre>
</section>
<!-- 케이스 2: POST + readBody -->
<section class="border rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono bg-green-100 text-green-700 px-2 py-0.5 rounded">POST /api/02-users</span>
<h2 class="font-semibold">케이스 2: POST + readBody</h2>
</div>
<p class="text-xs text-gray-500 mb-3">
이벤트 핸들러 안에서 <code class="bg-gray-100 px-1 rounded">$fetch</code> POST 요청합니다. name/email을 비워두면 400 에러가 반환됩니다.
</p>
<div class="space-y-2 mb-3">
<input v-model="newUserName" placeholder="이름 (필수)" class="block w-full border px-2 py-1.5 rounded text-sm" />
<input v-model="newUserEmail" placeholder="이메일 (필수)" class="block w-full border px-2 py-1.5 rounded text-sm" />
</div>
<button class="bg-green-500 text-white px-4 py-1.5 rounded text-sm hover:bg-green-600" @click="createUser">
사용자 생성
</button>
<pre v-if="createdUser" class="text-sm bg-green-50 text-green-800 p-3 rounded mt-3 overflow-auto">{{ createdUser }}</pre>
<p v-if="createUserError" class="text-red-500 text-sm mt-2"> {{ createUserError }}</p>
</section>
<!-- 케이스 3: getQuery -->
<section class="border rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono bg-blue-100 text-blue-700 px-2 py-0.5 rounded">GET /api/03-search</span>
<h2 class="font-semibold">케이스 3: getQuery 쿼리 파라미터</h2>
</div>
<p class="text-xs text-gray-500 mb-3">
<code class="bg-gray-100 px-1 rounded">useFetch</code> <code class="bg-gray-100 px-1 rounded">query</code> ref를 전달하면 변경 자동으로 재요청합니다.
</p>
<div class="flex gap-2 mb-3 flex-wrap">
<input v-model="keyword" placeholder="키워드" class="border px-2 py-1.5 rounded text-sm flex-1 min-w-24" />
<div class="flex gap-1 items-center">
<button
v-for="p in [1, 2]" :key="p"
class="border px-3 py-1.5 rounded text-sm"
:class="page === p ? 'bg-blue-500 text-white' : 'hover:bg-gray-50'"
@click="page = p"
>
{{ p }}페이지
</button>
</div>
</div>
<pre class="text-sm bg-gray-50 p-3 rounded overflow-auto">{{ searchResult }}</pre>
</section>
<!-- 케이스 4: 동적 라우트 -->
<section class="border rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono bg-blue-100 text-blue-700 px-2 py-0.5 rounded">GET /api/04-users/:id</span>
<h2 class="font-semibold">케이스 4: 동적 라우트 파라미터</h2>
</div>
<p class="text-xs text-gray-500 mb-3">
URL을 함수 <code class="bg-gray-100 px-1 rounded">() => `/api/04-users/${userId.value}`</code> 감싸면 변경 자동 재요청됩니다. ID 999 404 반환합니다.
</p>
<div class="flex gap-2 mb-3 flex-wrap">
<button
v-for="id in ['1', '2', '3', '999']" :key="id"
class="border px-3 py-1.5 rounded text-sm"
:class="userId === id ? 'bg-blue-500 text-white' : 'hover:bg-gray-50'"
@click="userId = id"
>
ID: {{ id }}
</button>
</div>
<pre v-if="user" class="text-sm bg-gray-50 p-3 rounded overflow-auto">{{ user }}</pre>
<div v-if="userError" class="bg-red-50 border border-red-200 rounded p-3 mt-2">
<p class="text-red-600 text-sm font-semibold">{{ userError.statusCode }} 에러</p>
<p class="text-red-500 text-xs">{{ userError.statusMessage }}</p>
</div>
</section>
<!-- 케이스 5: HTTP 메서드 분기 -->
<section class="border rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono bg-purple-100 text-purple-700 px-2 py-0.5 rounded">GET|PUT|DELETE /api/05-items/:id</span>
<h2 class="font-semibold">케이스 5: HTTP 메서드 분기</h2>
</div>
<p class="text-xs text-gray-500 mb-3">
파일명에 메서드 접미사가 없으면 모든 메서드를 수신합니다. <code class="bg-gray-100 px-1 rounded">event.method</code> 분기 처리합니다.
ID 999 404 반환합니다.
</p>
<div class="flex gap-2 mb-3 flex-wrap">
<button
v-for="id in ['1', '2', '999']" :key="id"
class="border px-3 py-1.5 rounded text-sm"
:class="itemId === id ? 'bg-purple-500 text-white' : 'hover:bg-gray-50'"
@click="itemId = id"
>
ID: {{ id }}
</button>
</div>
<div v-if="item" class="bg-gray-50 rounded p-3 mb-3 flex items-center justify-between">
<div>
<pre class="text-sm overflow-auto">{{ item }}</pre>
</div>
<div class="flex gap-2 ml-4 shrink-0">
<button
class="text-xs bg-yellow-400 hover:bg-yellow-500 text-white px-2 py-1 rounded"
@click="toggleItem"
>
done 토글 (PUT)
</button>
<button
class="text-xs bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded"
@click="deleteItem"
>
삭제 (DELETE)
</button>
</div>
</div>
<div v-if="itemError" class="bg-red-50 border border-red-200 rounded p-3">
<p class="text-red-600 text-sm font-semibold">{{ itemError.statusCode }} 에러</p>
<p class="text-red-500 text-xs">{{ itemError.statusMessage }}</p>
</div>
</section>
<!-- 케이스 6: createError -->
<section class="border rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono bg-red-100 text-red-700 px-2 py-0.5 rounded">GET /api/06-error-handling?type=</span>
<h2 class="font-semibold">케이스 6: createError 에러 처리</h2>
</div>
<p class="text-xs text-gray-500 mb-3">
에러 타입을 선택하고 요청해보세요. <code class="bg-gray-100 px-1 rounded">error.value.statusCode</code> <code class="bg-gray-100 px-1 rounded">error.value.data</code> 세부 정보에 접근합니다.
<span class="text-orange-500">random은 50% 확률로 성공/실패합니다.</span>
</p>
<div class="flex gap-2 mb-3 flex-wrap">
<button
v-for="t in ['notfound', 'forbidden', 'custom', 'random'] as const" :key="t"
class="border px-3 py-1.5 rounded text-sm"
:class="errorType === t ? 'bg-red-500 text-white' : 'hover:bg-gray-50'"
@click="errorType = t"
>
{{ t }}
</button>
</div>
<div class="mb-3 text-xs text-gray-400 bg-gray-50 rounded p-2">
<template v-if="errorType === 'notfound'"> <code>statusCode: 404</code> 반환</template>
<template v-else-if="errorType === 'forbidden'"> <code>statusCode: 403</code> + <code>data: &#123; required, current &#125;</code> 반환</template>
<template v-else-if="errorType === 'custom'"> <code>statusCode: 422</code> + <code>data.fields</code> 반환 ( 유효성 에러 패턴)</template>
<template v-else-if="errorType === 'random'">→ 성공 시 <code>&#123; result: "성공!" &#125;</code>, 실패 <code>statusCode: 500</code></template>
</div>
<button class="bg-red-500 text-white px-4 py-1.5 rounded text-sm hover:bg-red-600" @click="triggerError">
에러 요청 보내기
</button>
<pre v-if="errorResult" class="text-sm bg-green-50 text-green-800 p-3 rounded mt-3 overflow-auto">{{ errorResult }}</pre>
<div v-if="errorResponse" class="bg-red-50 border border-red-200 rounded p-3 mt-3">
<p class="text-red-600 text-sm font-semibold mb-1">{{ errorResponse.statusCode }}: {{ errorResponse.statusMessage }}</p>
<pre v-if="errorResponse.data" class="text-xs text-red-500 overflow-auto">data: {{ errorResponse.data }}</pre>
</div>
</section>
<!-- 케이스 7: server/utils + 헤더 -->
<section class="border rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono bg-blue-100 text-blue-700 px-2 py-0.5 rounded">GET /api/07-products</span>
<h2 class="font-semibold">케이스 7: getHeader + server/utils auto-import</h2>
</div>
<p class="text-xs text-gray-500 mb-3">
<code class="bg-gray-100 px-1 rounded">Authorization</code> 헤더 유무에 따라 응답이 달라집니다.
<code class="bg-gray-100 px-1 rounded">server/utils/response.ts</code> 헬퍼가 import 없이 사용됩니다.
</p>
<label class="flex items-center gap-2 mb-3 cursor-pointer select-none">
<div
class="w-10 h-5 rounded-full transition-colors"
:class="useAuthToken ? 'bg-blue-500' : 'bg-gray-300'"
@click="useAuthToken = !useAuthToken"
>
<div
class="w-5 h-5 bg-white rounded-full shadow transition-transform"
:class="useAuthToken ? 'translate-x-5' : 'translate-x-0'"
/>
</div>
<span class="text-sm">Authorization 헤더 포함</span>
<code v-if="useAuthToken" class="text-xs bg-blue-50 text-blue-600 px-1 rounded">Bearer {{ authToken }}</code>
</label>
<pre class="text-sm bg-gray-50 p-3 rounded overflow-auto">{{ products }}</pre>
<p class="text-xs text-gray-400 mt-1">
토큰 있을 : 가격(price) 포함 + authenticated: true<br />
토큰 없을 : 가격 숨김 + authenticated: false
</p>
</section>
<!-- 케이스 8: server/routes/ 웹훅 -->
<section class="border rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono bg-orange-100 text-orange-700 px-2 py-0.5 rounded">POST /08-webhook</span>
<h2 class="font-semibold">케이스 8: server/routes/ (접두사 없음)</h2>
</div>
<p class="text-xs text-gray-500 mb-3">
<code class="bg-gray-100 px-1 rounded">server/routes/</code> <code class="bg-gray-100 px-1 rounded">/api</code> 없이 URL을 직접 지정합니다.
시크릿을 "my-secret" 이외로 바꾸면 401 에러가 반환됩니다.
</p>
<div class="space-y-2 mb-3">
<div class="flex items-center gap-2">
<label class="text-xs w-28 shrink-0 text-gray-600">X-Webhook-Secret</label>
<input v-model="webhookSecret" class="border px-2 py-1.5 rounded text-sm flex-1" placeholder="my-secret" />
</div>
<div class="flex items-center gap-2">
<label class="text-xs w-28 shrink-0 text-gray-600">event</label>
<input v-model="webhookEvent" class="border px-2 py-1.5 rounded text-sm flex-1" placeholder="push" />
</div>
<div class="flex items-center gap-2">
<label class="text-xs w-28 shrink-0 text-gray-600">repo</label>
<input v-model="webhookRepo" class="border px-2 py-1.5 rounded text-sm flex-1" placeholder="my-project" />
</div>
</div>
<button
class="bg-orange-500 text-white px-4 py-1.5 rounded text-sm disabled:opacity-50 hover:bg-orange-600"
:disabled="webhookLoading"
@click="sendWebhook"
>
{{ webhookLoading ? "전송 중..." : "웹훅 전송" }}
</button>
<pre v-if="webhookResult" class="text-sm bg-green-50 text-green-800 p-3 rounded mt-3 overflow-auto">{{ webhookResult }}</pre>
<p v-if="webhookError" class="text-red-500 text-sm mt-2"> {{ webhookError }}</p>
</section>
</div>
</template>