diff --git a/apps/client/src/app/webhooks/page.tsx b/apps/client/src/app/webhooks/page.tsx new file mode 100644 index 00000000..97ab345f --- /dev/null +++ b/apps/client/src/app/webhooks/page.tsx @@ -0,0 +1,13 @@ +import { Suspense } from 'react'; + +import { WebhooksPage } from '@/views/webhooks'; + +const Webhooks = () => { + return ( + Loading...}> + + + ); +}; + +export default Webhooks; diff --git a/apps/client/src/entities/webhooks/index.ts b/apps/client/src/entities/webhooks/index.ts new file mode 100644 index 00000000..e95d42cf --- /dev/null +++ b/apps/client/src/entities/webhooks/index.ts @@ -0,0 +1,3 @@ +export * from './model/types'; +export * from './model/schema'; +export * from './model/constants'; diff --git a/apps/client/src/entities/webhooks/model/constants.ts b/apps/client/src/entities/webhooks/model/constants.ts new file mode 100644 index 00000000..766bf905 --- /dev/null +++ b/apps/client/src/entities/webhooks/model/constants.ts @@ -0,0 +1,23 @@ +export interface WebhookEventOption { + value: string; + label: string; + description: string; +} + +export const WEBHOOK_EVENTS: WebhookEventOption[] = [ + { value: 'STUDENT_GRADUATED', label: 'student.graduated', description: '학생 졸업 처리' }, + { value: 'STUDENT_WITHDRAWN', label: 'student.withdrawn', description: '학생 자퇴 처리' }, + { + value: 'STUDENT_STATUS_CHANGED', + label: 'student.status_changed', + description: '학생 상태 변경', + }, + { value: 'CLUB_CREATED', label: 'club.created', description: '동아리 생성' }, + { value: 'CLUB_UPDATED', label: 'club.updated', description: '동아리 정보 수정' }, + { value: 'CLUB_DELETED', label: 'club.deleted', description: '동아리 삭제' }, + { value: 'PROJECT_CREATED', label: 'project.created', description: '프로젝트 생성' }, + { value: 'PROJECT_UPDATED', label: 'project.updated', description: '프로젝트 수정' }, + { value: 'PROJECT_DELETED', label: 'project.deleted', description: '프로젝트 삭제' }, +]; + +export const MAX_WEBHOOKS_PER_ACCOUNT = 10; diff --git a/apps/client/src/entities/webhooks/model/schema.ts b/apps/client/src/entities/webhooks/model/schema.ts new file mode 100644 index 00000000..907253f6 --- /dev/null +++ b/apps/client/src/entities/webhooks/model/schema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const WebhookFormSchema = z.object({ + targetUrl: z + .url({ message: '올바른 URL 형식이 아닙니다.' }) + .regex(/^https?:\/\//, { message: 'URL은 http:// 또는 https://로 시작해야 합니다.' }), + events: z.array(z.string()).min(1, { message: '구독할 이벤트를 최소 1개 이상 선택해주세요.' }), +}); + +export type WebhookFormType = z.infer; diff --git a/apps/client/src/entities/webhooks/model/types.ts b/apps/client/src/entities/webhooks/model/types.ts new file mode 100644 index 00000000..d26718f6 --- /dev/null +++ b/apps/client/src/entities/webhooks/model/types.ts @@ -0,0 +1,33 @@ +import { ApiResponse } from '@repo/shared/types'; + +export interface Webhook { + id: number; + target_url: string; + events: string[]; + is_active: boolean; + created_at: string; +} + +export interface WebhookListData { + webhooks: Webhook[]; +} + +export interface CreateWebhookRequest { + target_url: string; + events: string[]; +} + +export interface UpdateWebhookRequest { + target_url?: string; + events?: string[]; +} + +export interface CreateWebhookData extends Webhook { + secret: string; +} + +export type CreateWebhookResponse = ApiResponse; +export type UpdateWebhookResponse = ApiResponse; +export type WebhookListResponse = ApiResponse; + +export type { WebhookFormType } from './schema'; diff --git a/apps/client/src/views/webhooks/index.ts b/apps/client/src/views/webhooks/index.ts new file mode 100644 index 00000000..9effffd4 --- /dev/null +++ b/apps/client/src/views/webhooks/index.ts @@ -0,0 +1,2 @@ +export { default as WebhooksPage } from './ui/WebhooksPage'; +export * from './model/useGetWebhooks'; diff --git a/apps/client/src/views/webhooks/model/useGetWebhooks.ts b/apps/client/src/views/webhooks/model/useGetWebhooks.ts new file mode 100644 index 00000000..27e84d5b --- /dev/null +++ b/apps/client/src/views/webhooks/model/useGetWebhooks.ts @@ -0,0 +1,19 @@ +import { get, webhookQueryKeys, webhookUrl } from '@repo/shared/api'; +import { minutesToMs } from '@repo/shared/utils'; +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { WebhookListResponse } from '@/entities/webhooks'; + +export const useGetWebhooks = ( + options?: Omit, 'queryKey' | 'queryFn'>, +) => + useQuery({ + queryKey: webhookQueryKeys.getWebhooks(), + queryFn: () => get(webhookUrl.getWebhooks()), + staleTime: minutesToMs(5), + gcTime: minutesToMs(10), + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + ...options, + }); diff --git a/apps/client/src/views/webhooks/ui/WebhooksPage/index.tsx b/apps/client/src/views/webhooks/ui/WebhooksPage/index.tsx new file mode 100644 index 00000000..9ea295ac --- /dev/null +++ b/apps/client/src/views/webhooks/ui/WebhooksPage/index.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useState } from 'react'; + +import { PageHeader } from '@repo/shared/ui'; +import { cn } from '@repo/shared/utils'; + +import { CreateWebhookData, MAX_WEBHOOKS_PER_ACCOUNT, Webhook } from '@/entities/webhooks'; +import { useGetWebhooks } from '@/views/webhooks'; +import { WebhookFormDialog, WebhookList, WebhookSuccessDialog } from '@/widgets/webhooks'; + +const WebhooksPage = () => { + const { data: webhooksData, isLoading } = useGetWebhooks(); + + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [editingWebhook, setEditingWebhook] = useState(null); + const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false); + const [createdWebhook, setCreatedWebhook] = useState(null); + + const handleEdit = (webhook: Webhook) => { + setEditingWebhook(webhook); + setIsEditDialogOpen(true); + }; + + const handleCreateSuccess = (data: CreateWebhookData) => { + setCreatedWebhook(data); + setIsSuccessDialogOpen(true); + }; + + const webhooks = webhooksData?.data.webhooks || []; + const isAtLimit = webhooks.length >= MAX_WEBHOOKS_PER_ACCOUNT; + + return ( + + + + } + /> + + {isAtLimit && ( + + {'>'} 계정당 최대 {MAX_WEBHOOKS_PER_ACCOUNT}개의 웹훅을 등록할 수 있습니다. + + )} + + + + + + + + + + + ); +}; + +export default WebhooksPage; diff --git a/apps/client/src/widgets/webhooks/index.ts b/apps/client/src/widgets/webhooks/index.ts new file mode 100644 index 00000000..125ed878 --- /dev/null +++ b/apps/client/src/widgets/webhooks/index.ts @@ -0,0 +1,7 @@ +export { default as WebhookFormDialog } from './ui/WebhookFormDialog'; +export { default as WebhookList } from './ui/WebhookList'; +export { default as WebhookSuccessDialog } from './ui/WebhookSuccessDialog'; +export * from './model/useCreateWebhook'; +export * from './model/useUpdateWebhook'; +export * from './model/useDeleteWebhook'; +export * from './model/useWebhookEventSelection'; diff --git a/apps/client/src/widgets/webhooks/model/useCreateWebhook.ts b/apps/client/src/widgets/webhooks/model/useCreateWebhook.ts new file mode 100644 index 00000000..b0d11ab6 --- /dev/null +++ b/apps/client/src/widgets/webhooks/model/useCreateWebhook.ts @@ -0,0 +1,18 @@ +import { post, webhookQueryKeys, webhookUrl } from '@repo/shared/api'; +import { UseMutationOptions, useMutation } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +import { CreateWebhookRequest, CreateWebhookResponse } from '@/entities/webhooks'; + +export const useCreateWebhook = ( + options?: Omit< + UseMutationOptions, + 'mutationKey' | 'mutationFn' + >, +) => + useMutation({ + mutationKey: webhookQueryKeys.postWebhook(), + mutationFn: (data: CreateWebhookRequest) => + post(webhookUrl.postWebhook(), data), + ...options, + }); diff --git a/apps/client/src/widgets/webhooks/model/useDeleteWebhook.ts b/apps/client/src/widgets/webhooks/model/useDeleteWebhook.ts new file mode 100644 index 00000000..7cef50e8 --- /dev/null +++ b/apps/client/src/widgets/webhooks/model/useDeleteWebhook.ts @@ -0,0 +1,16 @@ +import { del, webhookQueryKeys, webhookUrl } from '@repo/shared/api'; +import { BaseApiResponse } from '@repo/shared/types'; +import { UseMutationOptions, useMutation } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +export const useDeleteWebhook = ( + options?: Omit< + UseMutationOptions, + 'mutationKey' | 'mutationFn' + >, +) => + useMutation({ + mutationKey: webhookQueryKeys.deleteWebhook(), + mutationFn: (webhookId) => del(webhookUrl.deleteWebhook(webhookId)), + ...options, + }); diff --git a/apps/client/src/widgets/webhooks/model/useUpdateWebhook.ts b/apps/client/src/widgets/webhooks/model/useUpdateWebhook.ts new file mode 100644 index 00000000..63fe4d2f --- /dev/null +++ b/apps/client/src/widgets/webhooks/model/useUpdateWebhook.ts @@ -0,0 +1,23 @@ +import { patch, webhookQueryKeys, webhookUrl } from '@repo/shared/api'; +import { UseMutationOptions, useMutation } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +import { UpdateWebhookRequest, UpdateWebhookResponse } from '@/entities/webhooks'; + +interface UpdateWebhookVariables { + webhookId: number; + data: UpdateWebhookRequest; +} + +export const useUpdateWebhook = ( + options?: Omit< + UseMutationOptions, + 'mutationKey' | 'mutationFn' + >, +) => + useMutation({ + mutationKey: webhookQueryKeys.patchWebhook(), + mutationFn: ({ webhookId, data }: UpdateWebhookVariables) => + patch(webhookUrl.patchWebhook(webhookId), data), + ...options, + }); diff --git a/apps/client/src/widgets/webhooks/model/useWebhookEventSelection.ts b/apps/client/src/widgets/webhooks/model/useWebhookEventSelection.ts new file mode 100644 index 00000000..d5f6d7f7 --- /dev/null +++ b/apps/client/src/widgets/webhooks/model/useWebhookEventSelection.ts @@ -0,0 +1,37 @@ +'use client'; + +import { UseFormSetValue, UseFormWatch } from 'react-hook-form'; + +import { WebhookFormType } from '@/entities/webhooks'; + +interface UseWebhookEventSelectionParams { + watch: UseFormWatch; + setValue: UseFormSetValue; +} + +export const useWebhookEventSelection = ({ watch, setValue }: UseWebhookEventSelectionParams) => { + const fieldName = 'events'; + + const handleEventToggle = (event: string) => { + const currentEvents = watch(fieldName) || []; + + setValue( + fieldName, + currentEvents.includes(event) + ? currentEvents.filter((id) => id !== event) + : [...currentEvents, event], + { shouldValidate: true, shouldDirty: true }, + ); + }; + + const isEventChecked = (event: string) => { + const currentEvents = watch(fieldName) || []; + + return currentEvents.includes(event); + }; + + return { + handleEventToggle, + isEventChecked, + }; +}; diff --git a/apps/client/src/widgets/webhooks/ui/WebhookFormDialog/index.tsx b/apps/client/src/widgets/webhooks/ui/WebhookFormDialog/index.tsx new file mode 100644 index 00000000..d5c81bd7 --- /dev/null +++ b/apps/client/src/widgets/webhooks/ui/WebhookFormDialog/index.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Checkbox, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + FormErrorMessage, + Input, + Label, +} from '@repo/shared/ui'; +import { cn } from '@repo/shared/utils'; +import { useQueryClient } from '@tanstack/react-query'; +import { Pencil, Plus } from 'lucide-react'; +import { FieldError, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; + +import { + CreateWebhookData, + WEBHOOK_EVENTS, + Webhook, + WebhookFormSchema, + WebhookFormType, +} from '@/entities/webhooks'; +import { useCreateWebhook, useUpdateWebhook, useWebhookEventSelection } from '@/widgets/webhooks'; + +interface WebhookFormDialogProps { + mode: 'create' | 'edit'; + webhook?: Webhook; + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onCreateSuccess?: (data: CreateWebhookData) => void; + disabled?: boolean; +} + +const WebhookFormDialog = ({ + mode, + webhook, + trigger, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + onCreateSuccess, + disabled, +}: WebhookFormDialogProps) => { + const [internalOpen, setInternalOpen] = useState(false); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const pendingFormData = useRef(null); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const setOpen = isControlled ? controlledOnOpenChange! : setInternalOpen; + + const queryClient = useQueryClient(); + + const { isPending: isCreating, mutate: createWebhook } = useCreateWebhook({ + onSuccess: (data) => { + setOpen(false); + queryClient.invalidateQueries({ queryKey: ['webhooks'] }); + toast.success('웹훅이 생성되었습니다.'); + onCreateSuccess?.(data.data); + }, + onError: () => { + toast.error('웹훅 생성에 실패했습니다.'); + }, + }); + + const { isPending: isUpdating, mutate: updateWebhook } = useUpdateWebhook({ + onSuccess: () => { + setOpen(false); + queryClient.invalidateQueries({ queryKey: ['webhooks'] }); + toast.success('웹훅이 수정되었습니다.'); + }, + onError: () => { + toast.error('웹훅 수정에 실패했습니다.'); + }, + }); + + const { + register, + handleSubmit, + setValue, + watch, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(WebhookFormSchema), + defaultValues: { + targetUrl: '', + events: [], + }, + }); + + const { handleEventToggle, isEventChecked } = useWebhookEventSelection({ watch, setValue }); + + const isInitialized = useRef(false); + + useEffect(() => { + if (!open) { + isInitialized.current = false; + return; + } + + if (isInitialized.current) return; + + if (mode === 'edit' && webhook) { + reset({ + targetUrl: webhook.target_url, + events: [...webhook.events], + }); + isInitialized.current = true; + } else if (mode === 'create') { + reset({ + targetUrl: '', + events: [], + }); + isInitialized.current = true; + } + }, [mode, webhook, open, reset]); + + const onSubmit = (data: WebhookFormType) => { + if (mode === 'create') { + createWebhook({ + target_url: data.targetUrl, + events: data.events, + }); + } else if (mode === 'edit') { + pendingFormData.current = data; + setIsConfirmOpen(true); + } + }; + + const onConfirmSave = () => { + const data = pendingFormData.current; + if (!data || !webhook) return; + updateWebhook( + { + webhookId: webhook.id, + data: { + target_url: data.targetUrl, + events: data.events, + }, + }, + { onSettled: () => setIsConfirmOpen(false) }, + ); + }; + + const isPending = isCreating || isUpdating; + const title = mode === 'create' ? 'ADD WEBHOOK' : 'EDIT WEBHOOK'; + const submitText = mode === 'create' ? '추가' : '저장'; + + const defaultTrigger = + mode === 'create' ? ( + + + 웹훅 추가 + + ) : ( + + + + ); + + return ( + { + setOpen(v); + if (!v) reset(); + }} + > + {!isControlled && {trigger || defaultTrigger}} + + + {title} + + + + + 수신 URL + + + + + + {mode === 'edit' && webhook && ( + + + 웹훅 ID + + + {webhook.id} + + + )} + + + + 구독 이벤트 + + + 이 웹훅으로 전달받을 이벤트를 선택하세요. + + + + {WEBHOOK_EVENTS.map((event) => ( + + handleEventToggle(event.value)} + /> + + + {event.label} + + {event.description} + + + + + ))} + + + + + + {mode === 'edit' ? ( + <> + + + + 웹훅 수정 + + 정말로 이 웹훅 정보를 수정하시겠습니까? + + + + 취소 + 저장 + + + + + 저장 + + > + ) : ( + + {submitText} + + )} + + + + + ); +}; + +export default WebhookFormDialog; diff --git a/apps/client/src/widgets/webhooks/ui/WebhookList/index.tsx b/apps/client/src/widgets/webhooks/ui/WebhookList/index.tsx new file mode 100644 index 00000000..5cdb206d --- /dev/null +++ b/apps/client/src/widgets/webhooks/ui/WebhookList/index.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + PixelIconButton, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@repo/shared/ui'; +import { cn } from '@repo/shared/utils'; +import { useQueryClient } from '@tanstack/react-query'; +import { Pencil, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Webhook } from '@/entities/webhooks'; +import { useDeleteWebhook } from '@/widgets/webhooks'; + +interface WebhookListProps { + webhooks?: Webhook[]; + isLoading?: boolean; + onEdit: (webhook: Webhook) => void; +} + +interface WebhookListItemProps { + webhook: Webhook; + onEdit: (webhook: Webhook) => void; + onDelete: (id: number) => void; +} + +const formatDate = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString('ko-KR'); +}; + +const WebhookListItem = ({ webhook, onEdit, onDelete }: WebhookListItemProps) => { + return ( + + + {webhook.target_url} + + + + {webhook.events.map((event) => ( + + {event} + + ))} + + + + + {webhook.is_active ? 'active' : 'inactive'} + + + + {formatDate(webhook.created_at)} + + + + onEdit(webhook)}> + + + + + + + + + + + + + 웹훅 삭제 + + + 정말로 이 웹훅을 삭제하시겠습니까? 삭제 후에는 해당 이벤트가 더 이상 전송되지 + 않으며, 이 작업은 되돌릴 수 없습니다. + + + + 취소 + onDelete(webhook.id)} + className={cn( + 'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white', + )} + > + 삭제 + + + + + + + + ); +}; + +const WebhookList = ({ webhooks, isLoading, onEdit }: WebhookListProps) => { + const queryClient = useQueryClient(); + + const { mutate: deleteWebhook } = useDeleteWebhook({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['webhooks'] }); + toast.success('웹훅이 삭제되었습니다.'); + }, + onError: () => { + toast.error('웹훅 삭제에 실패했습니다.'); + }, + }); + + return ( + + + + 수신 URL + 구독 이벤트 + 상태 + 생성일 + 작업 + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, index) => ( + + + + + + + + + + + + + + + + + + )) + ) : webhooks && webhooks.length > 0 ? ( + webhooks.map((webhook) => ( + deleteWebhook(id)} + /> + )) + ) : ( + + + {'>'} 등록된 웹훅이 없습니다. + + + )} + + + ); +}; + +export default WebhookList; diff --git a/apps/client/src/widgets/webhooks/ui/WebhookSuccessDialog/index.tsx b/apps/client/src/widgets/webhooks/ui/WebhookSuccessDialog/index.tsx new file mode 100644 index 00000000..56a0b7d0 --- /dev/null +++ b/apps/client/src/widgets/webhooks/ui/WebhookSuccessDialog/index.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useCopyToClipboard } from '@repo/shared/hooks'; +import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Label } from '@repo/shared/ui'; +import { cn } from '@repo/shared/utils'; +import { AlertTriangle, Check, Copy } from 'lucide-react'; + +import { CreateWebhookData } from '@/entities/webhooks'; + +interface WebhookSuccessDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + webhook: CreateWebhookData | null; +} + +const WebhookSuccessDialog = ({ open, onOpenChange, webhook }: WebhookSuccessDialogProps) => { + const { copied: copiedSecret, copy: copySecret } = useCopyToClipboard({ + successMessage: 'Secret이 복사되었습니다.', + }); + + if (!webhook) return null; + + return ( + + + + + + WEBHOOK CREATED + + + + + + + + + 중요: Secret을 안전하게 저장하세요 + + + 서명 검증용 secret은 이 창을 닫으면 다시 확인할 수 없습니다. 반드시 복사하여 안전한 + 곳에 보관하세요. + + + + + + + + + 수신 URL + + {webhook.target_url} + + + + + Secret + + + + {webhook.secret} + + copySecret(webhook.secret)}> + {copiedSecret ? ( + + ) : ( + + )} + + + + + + + onOpenChange(false)}>확인 + + + + ); +}; + +export default WebhookSuccessDialog; diff --git a/apps/docs/src/app/api/http/page.mdx b/apps/docs/src/app/api/http/page.mdx index c4348326..448e7e4a 100644 --- a/apps/docs/src/app/api/http/page.mdx +++ b/apps/docs/src/app/api/http/page.mdx @@ -65,10 +65,6 @@ API 키는 UUID 형식이며, 30일마다 갱신이 필요합니다. 만료되 | `GET /v1/neis/meals` | NEIS 급식 데이터 조회 | `NEIS_READ` | | `GET /v1/neis/schedules` | NEIS 학사일정 데이터 조회 | `NEIS_READ` | | `GET /v1/neis/timetables` | NEIS 시간표 데이터 조회 | `NEIS_READ` | -| `POST /v1/webhooks` | Webhook 등록 | `WEBHOOK_WRITE` | -| `GET /v1/webhooks` | Webhook 목록 조회 | `WEBHOOK_WRITE` | -| `PATCH /v1/webhooks/{webhookId}` | Webhook 수정 | `WEBHOOK_WRITE` | -| `DELETE /v1/webhooks/{webhookId}` | Webhook 삭제 | `WEBHOOK_WRITE` | ### 요청 예시 @@ -142,5 +138,5 @@ async function fetchWithCache(url, apiKey, cachedETag) { - [프로젝트 데이터 OpenAPI](/api/http/project) - [NEIS 데이터 OpenAPI](/api/http/neis) -이벤트 기반 통합이 필요하다면 [Webhook 기술 문서](/api/webhook)를 참고하세요. SDK를 사용한 더 편리한 API 호출을 원한다면 [SDK 기술 문서](/api/sdk)를 참고하세요. +SDK를 사용한 더 편리한 API 호출을 원한다면 [SDK 기술 문서](/api/sdk)를 참고하세요. diff --git a/apps/docs/src/app/api/page.mdx b/apps/docs/src/app/api/page.mdx index 0345a0fd..89d1af5a 100644 --- a/apps/docs/src/app/api/page.mdx +++ b/apps/docs/src/app/api/page.mdx @@ -44,7 +44,6 @@ API 키 발급 페이지에서 필요한 권한 범위를 설정하여 API 키 | `PROJECT_READ` | 프로젝트 데이터 조회 권한 범위 | | `PROJECT_WRITE` | 프로젝트 데이터 수정 권한 범위 | | `NEIS_READ` | NEIS 데이터 조회 권한 범위 | -| `WEBHOOK_WRITE` | Webhook 등록·조회·수정·삭제 권한 범위 | ### 공통 사항 diff --git a/apps/docs/src/app/api/webhook/delete/page.mdx b/apps/docs/src/app/api/webhook/delete/page.mdx deleted file mode 100644 index 3f3dce86..00000000 --- a/apps/docs/src/app/api/webhook/delete/page.mdx +++ /dev/null @@ -1,133 +0,0 @@ -export const metadata = { title: 'Webhook 삭제' } - -# Webhook 삭제 - -### 개요 - -등록된 Webhook을 삭제합니다. 삭제된 Webhook은 복구할 수 없으며, 이후 발생하는 이벤트도 더 이상 전송되지 않습니다. - -### 인증 - -```http -X-API-KEY: your-api-key-here -``` - -### 엔드포인트 - -```http -DELETE /v1/webhooks/{webhookId} -``` - -### 권한 범위 - -`WEBHOOK_WRITE` 권한 범위가 필요합니다. - -### 경로 파라미터 - -| 파라미터 | 타입 | 필수 여부 | 설명 | 예시 | -|-------------|--------|-------|------------|-----| -| `webhookId` | `integer` | 필수 | Webhook ID | `1` | - -### 응답 형식 - -| 필드 | 타입 | 설명 | 예시 | -|-----------|----------|-------------|-------------------------------------| -| `status` | `String` | HTTP 상태 메시지 | `OK` | -| `code` | `integer` | HTTP 상태 코드 | `200` | -| `message` | `String` | 응답 메시지 | `Webhook을 성공적으로 삭제했습니다.` | -| `data` | `null` | 데이터 없음 | `null` | - -### 요청 예시 - -```bash -curl -X DELETE "https://openapi.datagsm.kr/v1/webhooks/1" \ - -H "X-API-KEY: your-api-key-here" -``` - -### 응답 예시 - -```json -{ - "status": "OK", - "code": 200, - "message": "Webhook을 성공적으로 삭제했습니다.", - "data": null -} -``` - -### 오류 응답 - -| 상태 코드 | 설명 | -|--------------------|-----------------------------------| -| `401 Unauthorized` | API 키가 유효하지 않거나 만료됨 | -| `403 Forbidden` | `WEBHOOK_WRITE` 권한 범위 부족 | -| `404 Not Found` | 해당 ID의 Webhook이 존재하지 않거나 소유자가 다름 | - -### 사용 예제 - - - - - response = client.send(request, - HttpResponse.BodyHandlers.ofString()); - return response.body(); - } -}`} /> - - diff --git a/apps/docs/src/app/api/webhook/events/page.mdx b/apps/docs/src/app/api/webhook/events/page.mdx deleted file mode 100644 index 1178789a..00000000 --- a/apps/docs/src/app/api/webhook/events/page.mdx +++ /dev/null @@ -1,305 +0,0 @@ -export const metadata = { title: '이벤트 페이로드 명세' } - -# 이벤트 페이로드 명세 - -### 개요 - -DataGSM Webhook이 외부 서버로 전송하는 이벤트 페이로드의 공통 구조와 각 이벤트별 `data` 필드 명세입니다. 모든 페이로드는 `application/json` Content-Type으로 HTTP POST 요청 본문에 담겨 전송됩니다. - -### 공통 구조 - -모든 이벤트 페이로드는 다음 공통 구조를 따릅니다. - -```json -{ - "id": "evt_abc123", - "event": "student.graduated", - "timestamp": "2026-05-13T10:00:00Z", - "data": { } -} -``` - -| 필드 | 타입 | 설명 | 예시 | -|-------------|----------|---------------------------------------------------|--------------------------| -| `id` | `String` | 이벤트 식별자 | `evt_abc123` | -| `event` | `String` | 이벤트 이름 ([WebhookEvent 목록](#webhookevent-목록) 참고) | `student.graduated` | -| `timestamp` | `String` | 이벤트 발생 시각 (`ISO-8601`) | `2026-05-13T10:00:00Z` | -| `data` | `Object` | 이벤트별 상세 데이터 | (이벤트별로 다름) | - -### WebhookEvent 목록 - -| 이벤트 이름 | 설명 | -|-------------------------|-------------| -| `student.graduated` | 학생 졸업 처리 | -| `student.withdrawn` | 학생 자퇴 처리 | -| `student.status_changed` | 학생 상태 변경 | -| `club.created` | 동아리 생성 | -| `club.updated` | 동아리 정보 수정 | -| `club.deleted` | 동아리 삭제 | -| `project.created` | 프로젝트 생성 | -| `project.updated` | 프로젝트 수정 | -| `project.deleted` | 프로젝트 삭제 | - ---- - -### `student.graduated` - -학생이 졸업 처리되었을 때 발송됩니다. - -#### data 필드 - -| 필드 | 타입 | 설명 | 예시 | -|--------------|----------|--------|---------------------| -| `student_id` | `integer` | 학생 ID | `1` | -| `name` | `String` | 학생 이름 | `홍길동` | -| `email` | `String` | 학생 이메일 | `s24080@gsm.hs.kr` | - -#### 예시 - -```json -{ - "id": "evt_abc123", - "event": "student.graduated", - "timestamp": "2026-05-13T10:00:00Z", - "data": { - "student_id": 1, - "name": "홍길동", - "email": "s24080@gsm.hs.kr" - } -} -``` - ---- - -### `student.withdrawn` - -학생이 자퇴 처리되었을 때 발송됩니다. - -#### data 필드 - -| 필드 | 타입 | 설명 | 예시 | -|--------------|----------|--------|---------------------| -| `student_id` | `integer` | 학생 ID | `1` | -| `name` | `String` | 학생 이름 | `홍길동` | -| `email` | `String` | 학생 이메일 | `s24080@gsm.hs.kr` | - -#### 예시 - -```json -{ - "id": "evt_abc124", - "event": "student.withdrawn", - "timestamp": "2026-05-13T10:05:00Z", - "data": { - "student_id": 1, - "name": "홍길동", - "email": "s24080@gsm.hs.kr" - } -} -``` - ---- - -### `student.status_changed` - -학생의 역할(`StudentRole`) 등 상태가 변경되었을 때 발송됩니다. - -#### data 필드 - -| 필드 | 타입 | 설명 | 예시 | -|--------------|----------|------------------------------------------------------------------------------------------|---------------------| -| `student_id` | `integer` | 학생 ID | `1` | -| `name` | `String` | 학생 이름 | `홍길동` | -| `email` | `String` | 학생 이메일 | `s24080@gsm.hs.kr` | -| `status` | `String` | 변경된 학생 역할 (`STUDENT_COUNCIL`, `DORMITORY_MANAGER`, `GENERAL_STUDENT`, `GRADUATE`, `WITHDRAWN`) | `STUDENT_COUNCIL` | - -#### 예시 - -```json -{ - "id": "evt_abc125", - "event": "student.status_changed", - "timestamp": "2026-05-13T10:10:00Z", - "data": { - "student_id": 1, - "name": "홍길동", - "email": "s24080@gsm.hs.kr", - "status": "STUDENT_COUNCIL" - } -} -``` - ---- - -### `club.created` - -동아리가 생성되었을 때 발송됩니다. - -#### data 필드 - -| 필드 | 타입 | 설명 | 예시 | -|-----------|----------|---------------------------------------------|--------------| -| `club_id` | `integer` | 동아리 ID | `1` | -| `name` | `String` | 동아리 이름 | `SW개발동아리` | -| `type` | `String` | 동아리 종류 (`MAJOR_CLUB`, `AUTONOMOUS_CLUB`) | `MAJOR_CLUB` | - -#### 예시 - -```json -{ - "id": "evt_abc126", - "event": "club.created", - "timestamp": "2026-05-13T11:00:00Z", - "data": { - "club_id": 1, - "name": "SW개발동아리", - "type": "MAJOR_CLUB" - } -} -``` - ---- - -### `club.updated` - -동아리 정보가 수정되었을 때 발송됩니다. - -#### data 필드 - -| 필드 | 타입 | 설명 | 예시 | -|-----------|----------|---------------------------------------------|--------------| -| `club_id` | `integer` | 동아리 ID | `1` | -| `name` | `String` | 변경 후 동아리 이름 | `SW개발동아리` | -| `type` | `String` | 변경 후 동아리 종류 (`MAJOR_CLUB`, `AUTONOMOUS_CLUB`) | `MAJOR_CLUB` | - -#### 예시 - -```json -{ - "id": "evt_abc127", - "event": "club.updated", - "timestamp": "2026-05-13T11:10:00Z", - "data": { - "club_id": 1, - "name": "SW개발동아리(개편)", - "type": "MAJOR_CLUB" - } -} -``` - ---- - -### `club.deleted` - -동아리가 삭제되었을 때 발송됩니다. - -#### data 필드 - -| 필드 | 타입 | 설명 | 예시 | -|-----------|----------|---------------|------------| -| `club_id` | `integer` | 동아리 ID | `1` | -| `name` | `String` | 삭제 시점의 동아리 이름 | `SW개발동아리` | - -#### 예시 - -```json -{ - "id": "evt_abc128", - "event": "club.deleted", - "timestamp": "2026-05-13T11:20:00Z", - "data": { - "club_id": 1, - "name": "SW개발동아리" - } -} -``` - ---- - -### `project.created` - -프로젝트가 생성되었을 때 발송됩니다. - -#### data 필드 - -| 필드 | 타입 | 설명 | 예시 | -|--------------|----------|------------|-----------------| -| `project_id` | `integer` | 프로젝트 ID | `1` | -| `name` | `String` | 프로젝트 이름 | `우주관측 프로젝트` | - -#### 예시 - -```json -{ - "id": "evt_abc129", - "event": "project.created", - "timestamp": "2026-05-13T12:00:00Z", - "data": { - "project_id": 1, - "name": "우주관측 프로젝트" - } -} -``` - ---- - -### `project.updated` - -프로젝트 정보가 수정되었을 때 발송됩니다. - -#### data 필드 - -| 필드 | 타입 | 설명 | 예시 | -|--------------|----------|----------------|-----------------| -| `project_id` | `integer` | 프로젝트 ID | `1` | -| `name` | `String` | 변경 후 프로젝트 이름 | `우주관측 프로젝트 v2` | - -#### 예시 - -```json -{ - "id": "evt_abc130", - "event": "project.updated", - "timestamp": "2026-05-13T12:10:00Z", - "data": { - "project_id": 1, - "name": "우주관측 프로젝트 v2" - } -} -``` - ---- - -### `project.deleted` - -프로젝트가 삭제되었을 때 발송됩니다. - -#### data 필드 - -| 필드 | 타입 | 설명 | 예시 | -|--------------|----------|----------------|-----------------| -| `project_id` | `integer` | 프로젝트 ID | `1` | -| `name` | `String` | 삭제 시점의 프로젝트 이름 | `우주관측 프로젝트` | - -#### 예시 - -```json -{ - "id": "evt_abc131", - "event": "project.deleted", - "timestamp": "2026-05-13T12:20:00Z", - "data": { - "project_id": 1, - "name": "우주관측 프로젝트" - } -} -``` - ---- - -### 페이로드 수신 시 유의사항 - -- 페이로드 본문 외에 `X-DataGSM-Signature` 헤더가 함께 전달됩니다. 위조 요청을 식별하기 위해 반드시 [서명을 검증](/api/webhook/signature)하세요. -- DataGSM 서버는 응답 본문을 사용하지 않습니다. 수신 서버는 가능한 한 빠르게 `2xx` 응답을 반환하고, 실제 처리는 별도 큐·잡으로 분리하는 것을 권장합니다. -- 응답이 `2xx`가 아니거나 타임아웃이 발생하면 지수 백오프(1s → 10s → 60s)로 최대 3회 재시도되며, 그 이후에는 영구 손실됩니다. -- 동일 이벤트가 재시도로 인해 중복 수신될 수 있으므로, 수신 서버는 멱등하게 처리해야 합니다. diff --git a/apps/docs/src/app/api/webhook/list/page.mdx b/apps/docs/src/app/api/webhook/list/page.mdx deleted file mode 100644 index 6a67dfb6..00000000 --- a/apps/docs/src/app/api/webhook/list/page.mdx +++ /dev/null @@ -1,148 +0,0 @@ -export const metadata = { title: 'Webhook 목록 조회' } - -# Webhook 목록 조회 - -### 개요 - -현재 API Key 소유자가 등록한 Webhook 목록을 조회합니다. 보안상 `secret`은 응답에 포함되지 않습니다. - -### 인증 - -```http -X-API-KEY: your-api-key-here -``` - -### 엔드포인트 - -```http -GET /v1/webhooks -``` - -### 권한 범위 - -`WEBHOOK_WRITE` 권한 범위가 필요합니다. - -### 요청 파라미터 - -없습니다. - -### 응답 형식 - -| 필드 | 타입 | 설명 | -|------------|-----------------------|------------| -| `webhooks` | `array` | Webhook 목록 | - -#### Webhook 객체 (WebhookResDto) - -| 필드 | 타입 | 설명 | 예시 | -|--------------|---------------------|---------------------|------------------------------------------| -| `id` | `integer` | Webhook ID | `1` | -| `target_url` | `String` | 수신 URL | `https://example.com/webhooks/datagsm` | -| `events` | `array` | 구독 이벤트 목록 | `["STUDENT_GRADUATED", "CLUB_CREATED"]` | -| `is_active` | `Boolean` | 활성화 여부 | `true` | -| `created_at` | `String` | 생성 일시 (`ISO-8601`) | `2026-05-13T10:00:00` | - -> `secret` 필드는 보안상 목록 조회 응답에 포함되지 않습니다. 등록 시 반환된 값을 별도로 저장하여 사용하세요. - -### 요청 예시 - -```bash -curl -X GET "https://openapi.datagsm.kr/v1/webhooks" \ - -H "X-API-KEY: your-api-key-here" -``` - -### 응답 예시 - -```json -{ - "webhooks": [ - { - "id": 1, - "target_url": "https://example.com/webhooks/datagsm", - "events": ["STUDENT_GRADUATED", "CLUB_CREATED"], - "is_active": true, - "created_at": "2026-05-13T10:00:00" - }, - { - "id": 2, - "target_url": "https://another.example.com/hook", - "events": ["PROJECT_CREATED", "PROJECT_UPDATED", "PROJECT_DELETED"], - "is_active": true, - "created_at": "2026-05-13T11:30:00" - } - ] -} -``` - -### 오류 응답 - -| 상태 코드 | 설명 | -|--------------------|-------------------------| -| `401 Unauthorized` | API 키가 유효하지 않거나 만료됨 | -| `403 Forbidden` | `WEBHOOK_WRITE` 권한 범위 부족 | - -### 사용 예제 - - - - - response = client.send(request, - HttpResponse.BodyHandlers.ofString()); - return response.body(); - } -}`} /> - - diff --git a/apps/docs/src/app/api/webhook/page.mdx b/apps/docs/src/app/api/webhook/page.mdx deleted file mode 100644 index 65455043..00000000 --- a/apps/docs/src/app/api/webhook/page.mdx +++ /dev/null @@ -1,129 +0,0 @@ -import { AlertCircle, Shield } from 'lucide-react' - -export const metadata = { title: 'Webhook' } - -# Webhook - -### 개요 - -DataGSM Webhook은 학생, 동아리, 프로젝트 도메인에서 발생하는 이벤트를 외부 서버로 실시간 전달하는 기능입니다. 이벤트가 발생하면 등록한 수신 URL로 HTTP POST 요청이 비동기로 발송되므로, 외부 서버가 DataGSM 데이터를 주기적으로 폴링하지 않아도 변경 사항을 즉시 반영할 수 있습니다. - - - - - - - Webhook은 API Key 인증 경로에서만 지원됩니다 - - - OAuth Access Token으로는 Webhook을 등록·관리할 수 없습니다. API Key의 권한 범위 형식과 OAuth의 권한 범위 형식이 달라 통합 검증이 불가능하며, OAuth 경로에는 서버 간 통신용 엔드포인트도 제공되지 않습니다. - - - - - -### 사용 시나리오 - -- 학생이 졸업·자퇴 처리되었을 때 외부 알림 시스템에 자동 통지 -- 동아리가 생성·수정·삭제될 때 외부 서비스의 동아리 데이터 동기화 -- 프로젝트 변경 사항을 외부 대시보드에 실시간 반영 - -### 인증 - -Webhook 관리 API는 API Key를 사용합니다. 모든 요청은 HTTP 헤더에 API 키를 포함해야 합니다. - -```http -X-API-KEY: your-api-key-here -``` - -### 권한 범위 - -Webhook 관리 API를 호출하려면 `WEBHOOK_WRITE` 권한 범위가 필요합니다. API 키 발급 시 해당 권한 범위를 선택하여 발급받으세요. - -| 권한 범위 설정 | 설명 | -|-----------------|-------------------| -| `WEBHOOK_WRITE` | Webhook 등록·조회·수정·삭제 권한 범위 | - -### 기본 URL - -모든 Webhook 관리 API 요청은 다음 기본 URL을 사용합니다. - -```text -https://openapi.datagsm.kr -``` - -### 엔드포인트 목록 - -| 메서드 | 엔드포인트 | 설명 | -|---------|------------------------|----------------| -| `POST` | `/v1/webhooks` | Webhook 등록 | -| `GET` | `/v1/webhooks` | Webhook 목록 조회 | -| `PATCH` | `/v1/webhooks/{webhookId}` | Webhook 수정 | -| `DELETE` | `/v1/webhooks/{webhookId}` | Webhook 삭제 | - -### 등록 가능 개수 - -하나의 계정에는 최대 **10개**의 Webhook을 등록할 수 있습니다. 초과 시 `400 Bad Request`가 반환됩니다. - -### 지원 이벤트 - -현재 지원되는 9개 이벤트는 다음과 같습니다. - -| 이벤트 | 트리거 | 관련 도메인 | -|-------------------------|----------------|---------| -| `student.graduated` | 학생 졸업 처리 | student | -| `student.withdrawn` | 학생 자퇴 처리 | student | -| `student.status_changed` | 학생 상태 변경 | student | -| `club.created` | 동아리 생성 | club | -| `club.updated` | 동아리 정보 수정 | club | -| `club.deleted` | 동아리 삭제 | club | -| `project.created` | 프로젝트 생성 | project | -| `project.updated` | 프로젝트 수정 | project | -| `project.deleted` | 프로젝트 삭제 | project | - -각 이벤트의 payload 형식은 [이벤트 페이로드 명세](/api/webhook/events)에서 확인할 수 있습니다. - -### 전송 정책 - -| 항목 | 정책 | -|--------------|------------------------------------------------------| -| **전송 방식** | `@Async` 기반 비동기 HTTP POST 요청 | -| **재시도** | 최대 3회, 지수 백오프 (1s → 10s → 60s) | -| **전달 이력 저장** | 미지원 (3회 재시도 후 실패 시 서버 로그에만 기록) | -| **서명 헤더** | `X-DataGSM-Signature: sha256=` | -| **Content-Type** | `application/json` | - -3회 재시도 후에도 전달에 실패하면 해당 이벤트는 영구적으로 손실됩니다. 수신 서버가 일시적으로 다운된 상태에서 발생한 이벤트는 재발송되지 않으므로, 중요한 데이터는 별도의 정합성 점검 로직을 함께 운영하는 것을 권장합니다. - -### secret 관리 - - - - - - - secret은 등록 응답에서만 노출됩니다 - - - Webhook 등록 시 발급되는 secret은 등록 응답에서 단 한 번만 확인할 수 있습니다. 이후 목록 조회 응답에는 포함되지 않으며, 분실 시 해당 Webhook을 삭제하고 다시 등록해야 합니다. - - - - - -secret은 64자 hex 문자열이며, 수신 서버가 위조 요청을 식별하기 위한 HMAC-SHA256 서명 키로 사용됩니다. 서명 검증 방법은 [서명 검증 가이드](/api/webhook/signature)를 참고하세요. - -### 빠른 시작 - -1. API 키 발급 페이지에서 `WEBHOOK_WRITE` 권한 범위를 포함한 API 키를 발급받습니다. -2. [Webhook 등록 API](/api/webhook/register)로 수신 URL과 구독 이벤트를 등록하고, 응답에 포함된 `secret`을 안전한 곳에 보관합니다. -3. 수신 서버에서 `X-DataGSM-Signature` 헤더를 검증하여 요청의 진위를 확인합니다. - -### 다음 단계 - -- [Webhook 등록](/api/webhook/register) -- [Webhook 목록 조회](/api/webhook/list) -- [Webhook 수정](/api/webhook/update) -- [Webhook 삭제](/api/webhook/delete) -- [이벤트 페이로드 명세](/api/webhook/events) -- [서명 검증 가이드](/api/webhook/signature) diff --git a/apps/docs/src/app/api/webhook/register/page.mdx b/apps/docs/src/app/api/webhook/register/page.mdx deleted file mode 100644 index 5440acb9..00000000 --- a/apps/docs/src/app/api/webhook/register/page.mdx +++ /dev/null @@ -1,185 +0,0 @@ -export const metadata = { title: 'Webhook 등록' } - -# Webhook 등록 - -### 개요 - -새로운 Webhook을 등록합니다. 등록 응답에서 반환되는 `secret`은 이 응답에서만 확인할 수 있으므로 반드시 안전하게 보관해야 합니다. - -### 인증 - -```http -X-API-KEY: your-api-key-here -``` - -### 엔드포인트 - -```http -POST /v1/webhooks -``` - -### 권한 범위 - -`WEBHOOK_WRITE` 권한 범위가 필요합니다. - -### 요청 본문 - -| 필드 | 타입 | 필수 여부 | 설명 | 예시 | -|--------------|---------------------|-------|-------------------------------------------------------------------------------------|------------------------------------------| -| `target_url` | `String` | 필수 | 이벤트를 수신할 외부 서버의 URL. `http://` 또는 `https://`로 시작해야 합니다. | `https://example.com/webhooks/datagsm` | -| `events` | `array` | 필수 | 구독할 이벤트 목록. 비어 있을 수 없습니다. | `["STUDENT_GRADUATED", "CLUB_CREATED"]` | - -#### WebhookEvent 값 - -| Enum 값 | 이벤트 이름 | 설명 | -|--------------------------|-----------------------|-------------| -| `STUDENT_GRADUATED` | `student.graduated` | 학생 졸업 처리 | -| `STUDENT_WITHDRAWN` | `student.withdrawn` | 학생 자퇴 처리 | -| `STUDENT_STATUS_CHANGED` | `student.status_changed` | 학생 상태 변경 | -| `CLUB_CREATED` | `club.created` | 동아리 생성 | -| `CLUB_UPDATED` | `club.updated` | 동아리 정보 수정 | -| `CLUB_DELETED` | `club.deleted` | 동아리 삭제 | -| `PROJECT_CREATED` | `project.created` | 프로젝트 생성 | -| `PROJECT_UPDATED` | `project.updated` | 프로젝트 수정 | -| `PROJECT_DELETED` | `project.deleted` | 프로젝트 삭제 | - -### 응답 형식 - -| 필드 | 타입 | 설명 | 예시 | -|--------------|---------------------|-----------------------------------------------------|------------------------------------------| -| `id` | `integer` | Webhook ID | `1` | -| `target_url` | `String` | 등록된 수신 URL | `https://example.com/webhooks/datagsm` | -| `events` | `array` | 구독 이벤트 목록 | `["STUDENT_GRADUATED", "CLUB_CREATED"]` | -| `is_active` | `Boolean` | 활성화 여부 | `true` | -| `created_at` | `String` | 생성 일시 (`ISO-8601`) | `2026-05-13T10:00:00` | -| `secret` | `String` | HMAC-SHA256 서명용 secret (64자 hex). **이 응답에서만 노출됩니다.** | `a1b2c3...` (64자) | - -### 요청 예시 - -```bash -curl -X POST "https://openapi.datagsm.kr/v1/webhooks" \ - -H "X-API-KEY: your-api-key-here" \ - -H "Content-Type: application/json" \ - -d '{ - "target_url": "https://example.com/webhooks/datagsm", - "events": ["STUDENT_GRADUATED", "CLUB_CREATED"] - }' -``` - -### 응답 예시 - -```json -{ - "id": 1, - "target_url": "https://example.com/webhooks/datagsm", - "events": ["STUDENT_GRADUATED", "CLUB_CREATED"], - "is_active": true, - "created_at": "2026-05-13T10:00:00", - "secret": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" -} -``` - -### 오류 응답 - -| 상태 코드 | 설명 | -|--------------------|------------------------------------------| -| `400 Bad Request` | 검증 실패 (URL 형식·이벤트 누락) 또는 Webhook 최대 개수(10개) 초과 | -| `401 Unauthorized` | API 키가 유효하지 않거나 만료됨 | -| `403 Forbidden` | `WEBHOOK_WRITE` 권한 범위 부족 | - -### 사용 예제 - - - - - response = client.send(request, - HttpResponse.BodyHandlers.ofString()); - return response.body(); - } -}`} /> - - diff --git a/apps/docs/src/app/api/webhook/signature/page.mdx b/apps/docs/src/app/api/webhook/signature/page.mdx deleted file mode 100644 index e3c23de1..00000000 --- a/apps/docs/src/app/api/webhook/signature/page.mdx +++ /dev/null @@ -1,185 +0,0 @@ -import { Shield } from 'lucide-react' - -export const metadata = { title: '서명 검증 가이드' } - -# 서명 검증 가이드 - -### 개요 - -DataGSM이 외부 서버로 Webhook을 전송할 때, 요청이 실제로 DataGSM에서 발송되었는지 검증할 수 있도록 HMAC-SHA256 기반 서명을 HTTP 헤더에 함께 보냅니다. 수신 서버는 이 서명을 검증하여 위조된 요청을 걸러내야 합니다. - - - - - - - 서명 검증은 선택이 아니라 필수입니다 - - - 검증을 건너뛰면 누구나 수신 URL을 알아낸 즉시 위조 페이로드를 보낼 수 있습니다. 학생·동아리·프로젝트 데이터의 신뢰성을 위해 모든 Webhook 핸들러에서 반드시 서명을 검증하세요. - - - - - -### 시그니처 헤더 - -DataGSM 서버는 Webhook을 전송할 때 다음 헤더를 함께 보냅니다. - -```http -X-DataGSM-Signature: sha256= -``` - -- `secret`: Webhook 등록 응답에서 발급된 64자 hex secret -- `payload`: HTTP 요청 본문(JSON) 원문 바이트(`UTF-8`) -- 서명 값은 HMAC-SHA256 결과를 소문자 hex로 인코딩한 문자열입니다. - -### 검증 절차 - -1. 요청 헤더에서 `X-DataGSM-Signature` 값을 읽고 `sha256=` 접두사를 제거합니다. -2. 등록 시 저장해 둔 `secret`을 사용해 요청 본문 원문에 대한 HMAC-SHA256을 계산합니다. -3. 계산한 서명과 헤더의 서명을 **상수 시간 비교**로 일치 여부를 확인합니다. 단순 문자열 비교는 타이밍 공격에 취약하므로 언어별 안전한 비교 함수를 사용하세요. -4. 일치하지 않으면 요청을 거부합니다 (`401` 또는 `403` 응답 권장). - -> JSON 파싱 결과가 아닌 **요청 본문 원문 바이트**로 서명을 계산해야 합니다. 프레임워크가 본문을 미리 파싱하면 공백·필드 순서 등이 달라져 서명 검증이 실패할 수 있습니다. - -### 검증 예제 - - - { - const signatureHeader = req.header('X-DataGSM-Signature'); - if (!signatureHeader || !signatureHeader.startsWith('sha256=')) { - return res.status(401).send('missing signature'); - } - const received = signatureHeader.slice('sha256='.length); - - const expected = crypto - .createHmac('sha256', WEBHOOK_SECRET) - .update(req.body) // Buffer - .digest('hex'); - - const valid = - received.length === expected.length && - crypto.timingSafeEqual( - Buffer.from(received, 'utf8'), - Buffer.from(expected, 'utf8') - ); - - if (!valid) return res.status(401).send('invalid signature'); - - const payload = JSON.parse(req.body.toString('utf8')); - // payload.event, payload.data 처리 - res.sendStatus(200); - } -);`} /> - - - { - if (!signature.startsWith("sha256=")) return ResponseEntity.status(401).build() - val received = signature.removePrefix("sha256=") - - val mac = Mac.getInstance("HmacSHA256").apply { - init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")) - } - val expected = mac.doFinal(rawBody) - .joinToString("") { "%02x".format(it) } - - val valid = received.length == expected.length && - received.zip(expected).fold(0) { acc, (a, b) -> acc or (a.code xor b.code) } == 0 - if (!valid) return ResponseEntity.status(401).build() - - // rawBody를 파싱하여 payload.event, payload.data 처리 - return ResponseEntity.ok().build() - } -}`} /> - - -### 운영 체크리스트 - -- [ ] secret을 환경 변수·시크릿 매니저 등 안전한 저장소에 보관합니다. -- [ ] 본문 파싱 전 원문 바이트로 서명을 계산합니다. -- [ ] 서명 비교는 상수 시간 비교 함수를 사용합니다. -- [ ] 검증 실패 시 `2xx`가 아닌 응답으로 거부합니다. -- [ ] 동일 이벤트의 중복 수신 가능성을 고려해 핸들러를 멱등하게 구현합니다. -- [ ] HTTPS 엔드포인트만 등록합니다. (`http://`는 등록 자체는 가능하지만 평문 노출 위험이 있어 권장하지 않습니다.) diff --git a/apps/docs/src/app/api/webhook/update/page.mdx b/apps/docs/src/app/api/webhook/update/page.mdx deleted file mode 100644 index 09be9659..00000000 --- a/apps/docs/src/app/api/webhook/update/page.mdx +++ /dev/null @@ -1,168 +0,0 @@ -export const metadata = { title: 'Webhook 수정' } - -# Webhook 수정 - -### 개요 - -등록된 Webhook의 수신 URL 또는 구독 이벤트를 수정합니다. `target_url`과 `events` 모두 선택 필드이며, `null` 또는 미포함 시 해당 필드는 변경되지 않습니다. - -### 인증 - -```http -X-API-KEY: your-api-key-here -``` - -### 엔드포인트 - -```http -PATCH /v1/webhooks/{webhookId} -``` - -### 권한 범위 - -`WEBHOOK_WRITE` 권한 범위가 필요합니다. - -### 경로 파라미터 - -| 파라미터 | 타입 | 필수 여부 | 설명 | 예시 | -|-------------|--------|-------|------------|-----| -| `webhookId` | `integer` | 필수 | Webhook ID | `1` | - -### 요청 본문 - -| 필드 | 타입 | 필수 여부 | 설명 | 예시 | -|--------------|----------------------|-------|---------------------------------------------------------------------|------------------------------------------| -| `target_url` | `String?` | 선택 | 변경할 수신 URL. `http://` 또는 `https://`로 시작해야 합니다. `null`이면 변경 없음. | `https://example.com/webhooks/new` | -| `events` | `array?` | 선택 | 변경할 구독 이벤트 목록. `null`이면 변경 없음. | `["STUDENT_WITHDRAWN"]` | - -> `events`를 새로운 집합으로 전달하면 기존 구독 이벤트는 모두 대체됩니다. 일부만 추가·제거하는 PATCH 시맨틱이 아닌, 전체 교체 방식임에 주의하세요. - -### 응답 형식 - -수정 후의 Webhook 상태를 반환합니다. - -| 필드 | 타입 | 설명 | 예시 | -|--------------|---------------------|------------|------------------------------------------| -| `id` | `integer` | Webhook ID | `1` | -| `target_url` | `String` | 수신 URL | `https://example.com/webhooks/new` | -| `events` | `array` | 구독 이벤트 목록 | `["STUDENT_WITHDRAWN"]` | -| `is_active` | `Boolean` | 활성화 여부 | `true` | -| `created_at` | `String` | 생성 일시 | `2026-05-13T10:00:00` | - -### 요청 예시 - -```bash -# 수신 URL만 변경 -curl -X PATCH "https://openapi.datagsm.kr/v1/webhooks/1" \ - -H "X-API-KEY: your-api-key-here" \ - -H "Content-Type: application/json" \ - -d '{ - "target_url": "https://example.com/webhooks/new" - }' - -# 구독 이벤트만 변경 -curl -X PATCH "https://openapi.datagsm.kr/v1/webhooks/1" \ - -H "X-API-KEY: your-api-key-here" \ - -H "Content-Type: application/json" \ - -d '{ - "events": ["STUDENT_WITHDRAWN", "STUDENT_STATUS_CHANGED"] - }' -``` - -### 응답 예시 - -```json -{ - "id": 1, - "target_url": "https://example.com/webhooks/new", - "events": ["STUDENT_WITHDRAWN", "STUDENT_STATUS_CHANGED"], - "is_active": true, - "created_at": "2026-05-13T10:00:00" -} -``` - -### 오류 응답 - -| 상태 코드 | 설명 | -|--------------------|-----------------------------------| -| `400 Bad Request` | 검증 실패 (URL 형식 오류 등) | -| `401 Unauthorized` | API 키가 유효하지 않거나 만료됨 | -| `403 Forbidden` | `WEBHOOK_WRITE` 권한 범위 부족 | -| `404 Not Found` | 해당 ID의 Webhook이 존재하지 않거나 소유자가 다름 | - -### 사용 예제 - - - - - response = client.send(request, - HttpResponse.BodyHandlers.ofString()); - return response.body(); - } -}`} /> - - diff --git a/apps/docs/src/widgets/docs/model/constants.ts b/apps/docs/src/widgets/docs/model/constants.ts index 44935490..5373ca04 100644 --- a/apps/docs/src/widgets/docs/model/constants.ts +++ b/apps/docs/src/widgets/docs/model/constants.ts @@ -79,36 +79,6 @@ export const docsSections: DocsSection[] = [ }, ], }, - { - label: 'Webhook', - href: '/api/webhook', - children: [ - { - label: 'Webhook 등록', - href: '/api/webhook/register', - }, - { - label: 'Webhook 목록 조회', - href: '/api/webhook/list', - }, - { - label: 'Webhook 수정', - href: '/api/webhook/update', - }, - { - label: 'Webhook 삭제', - href: '/api/webhook/delete', - }, - { - label: '이벤트 페이로드 명세', - href: '/api/webhook/events', - }, - { - label: '서명 검증 가이드', - href: '/api/webhook/signature', - }, - ], - }, ], }, { diff --git a/packages/shared/src/api/apiUrls.ts b/packages/shared/src/api/apiUrls.ts index a6c533a5..e619ff33 100644 --- a/packages/shared/src/api/apiUrls.ts +++ b/packages/shared/src/api/apiUrls.ts @@ -175,6 +175,13 @@ export const applicationUrl = { postApplicationScope: (applicationId: string) => `/v1/applications/${applicationId}/scopes`, } as const; +export const webhookUrl = { + getWebhooks: () => '/v1/webhooks', + postWebhook: () => '/v1/webhooks', + patchWebhook: (webhookId: number) => `/v1/webhooks/${webhookId}`, + deleteWebhook: (webhookId: number) => `/v1/webhooks/${webhookId}`, +} as const; + export const healthUrl = { getHealth: () => '/v1/health', } as const; diff --git a/packages/shared/src/api/queryKeys.ts b/packages/shared/src/api/queryKeys.ts index 7c3dea96..8c188545 100644 --- a/packages/shared/src/api/queryKeys.ts +++ b/packages/shared/src/api/queryKeys.ts @@ -111,6 +111,13 @@ export const applicationQueryKeys = { postApplicationScope: () => ['applications', 'scopes', 'create'] as const, } as const; +export const webhookQueryKeys = { + getWebhooks: () => ['webhooks', 'my'] as const, + postWebhook: () => ['webhooks', 'create'] as const, + patchWebhook: () => ['webhooks', 'update'] as const, + deleteWebhook: () => ['webhooks', 'delete'] as const, +} as const; + export const healthQueryKeys = { getHealth: () => ['health', 'check'] as const, } as const; diff --git a/packages/shared/src/constants/navigation.ts b/packages/shared/src/constants/navigation.ts index 744df901..4856bcaf 100644 --- a/packages/shared/src/constants/navigation.ts +++ b/packages/shared/src/constants/navigation.ts @@ -7,6 +7,7 @@ export const NAV_LINKS = { { href: '/', label: 'APIKey' }, { href: '/clients', label: 'Client' }, { href: '/application', label: 'Application' }, + { href: '/webhooks', label: 'Webhook' }, { href: DOCS_URL, label: 'Docs' }, { href: STATUS_URL, label: 'Status' }, { href: '/myinfo', label: 'My' }, @@ -21,6 +22,7 @@ export const NAV_LINKS = { { href: CLIENT_URL, label: 'APIKey' }, { href: `${CLIENT_URL}/clients`, label: 'Client' }, { href: `${CLIENT_URL}/application`, label: 'Application' }, + { href: `${CLIENT_URL}/webhooks`, label: 'Webhook' }, { href: '/', label: 'Docs' }, { href: STATUS_URL, label: 'Status' }, { href: `${CLIENT_URL}/myinfo`, label: 'My' }, @@ -29,6 +31,7 @@ export const NAV_LINKS = { { href: CLIENT_URL, label: 'APIKey' }, { href: `${CLIENT_URL}/clients`, label: 'Client' }, { href: `${CLIENT_URL}/application`, label: 'Application' }, + { href: `${CLIENT_URL}/webhooks`, label: 'Webhook' }, { href: DOCS_URL, label: 'Docs' }, { href: '/', label: 'Status' }, { href: `${CLIENT_URL}/myinfo`, label: 'My' },
+ {'>'} 계정당 최대 {MAX_WEBHOOKS_PER_ACCOUNT}개의 웹훅을 등록할 수 있습니다. +
+ {webhook.id} +
+ 이 웹훅으로 전달받을 이벤트를 선택하세요. +
{event.label}
+ {event.description} +
+ 중요: Secret을 안전하게 저장하세요 +
+ 서명 검증용 secret은 이 창을 닫으면 다시 확인할 수 없습니다. 반드시 복사하여 안전한 + 곳에 보관하세요. +
{webhook.target_url}
+ {webhook.secret} +
secret