- 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.
401 lines
18 KiB
Vue
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: { required, current }</code> 반환</template>
|
|
<template v-else-if="errorType === 'custom'">→ <code>statusCode: 422</code> + <code>data.fields</code> 반환 (폼 유효성 에러 패턴)</template>
|
|
<template v-else-if="errorType === 'random'">→ 성공 시 <code>{ result: "성공!" }</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>
|