569 lines
21 KiB
TypeScript
569 lines
21 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* HTTP 요청 노드 속성 편집
|
|
*/
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Plus, Trash2, Globe, Key, FileJson, Settings } from "lucide-react";
|
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
|
import type { HttpRequestActionNodeData } from "@/types/node-editor";
|
|
|
|
interface HttpRequestActionPropertiesProps {
|
|
nodeId: string;
|
|
data: HttpRequestActionNodeData;
|
|
}
|
|
|
|
export function HttpRequestActionProperties({ nodeId, data }: HttpRequestActionPropertiesProps) {
|
|
const { updateNode } = useFlowEditorStore();
|
|
|
|
// 로컬 상태
|
|
const [displayName, setDisplayName] = useState(data.displayName || "HTTP 요청");
|
|
const [url, setUrl] = useState(data.url || "");
|
|
const [method, setMethod] = useState<HttpRequestActionNodeData["method"]>(data.method || "GET");
|
|
const [bodyType, setBodyType] = useState<HttpRequestActionNodeData["bodyType"]>(data.bodyType || "none");
|
|
const [body, setBody] = useState(data.body || "");
|
|
|
|
// 인증
|
|
const [authType, setAuthType] = useState<NonNullable<HttpRequestActionNodeData["authentication"]>["type"]>(
|
|
data.authentication?.type || "none"
|
|
);
|
|
const [authUsername, setAuthUsername] = useState(data.authentication?.username || "");
|
|
const [authPassword, setAuthPassword] = useState(data.authentication?.password || "");
|
|
const [authToken, setAuthToken] = useState(data.authentication?.token || "");
|
|
const [authApiKey, setAuthApiKey] = useState(data.authentication?.apiKey || "");
|
|
const [authApiKeyName, setAuthApiKeyName] = useState(data.authentication?.apiKeyName || "X-API-Key");
|
|
const [authApiKeyLocation, setAuthApiKeyLocation] = useState<"header" | "query">(
|
|
data.authentication?.apiKeyLocation || "header"
|
|
);
|
|
|
|
// 옵션
|
|
const [timeout, setTimeout] = useState(data.options?.timeout?.toString() || "30000");
|
|
const [followRedirects, setFollowRedirects] = useState(data.options?.followRedirects ?? true);
|
|
const [retryCount, setRetryCount] = useState(data.options?.retryCount?.toString() || "0");
|
|
const [retryDelay, setRetryDelay] = useState(data.options?.retryDelay?.toString() || "1000");
|
|
|
|
// 헤더
|
|
const [headers, setHeaders] = useState<Array<{ key: string; value: string }>>(
|
|
Object.entries(data.headers || {}).map(([key, value]) => ({ key, value }))
|
|
);
|
|
|
|
// 쿼리 파라미터
|
|
const [queryParams, setQueryParams] = useState<Array<{ key: string; value: string }>>(
|
|
Object.entries(data.queryParams || {}).map(([key, value]) => ({ key, value }))
|
|
);
|
|
|
|
// 응답 처리
|
|
const [extractPath, setExtractPath] = useState(data.responseHandling?.extractPath || "");
|
|
const [saveToVariable, setSaveToVariable] = useState(data.responseHandling?.saveToVariable || "");
|
|
|
|
// 데이터 변경 시 로컬 상태 동기화
|
|
useEffect(() => {
|
|
setDisplayName(data.displayName || "HTTP 요청");
|
|
setUrl(data.url || "");
|
|
setMethod(data.method || "GET");
|
|
setBodyType(data.bodyType || "none");
|
|
setBody(data.body || "");
|
|
setAuthType(data.authentication?.type || "none");
|
|
setAuthUsername(data.authentication?.username || "");
|
|
setAuthPassword(data.authentication?.password || "");
|
|
setAuthToken(data.authentication?.token || "");
|
|
setAuthApiKey(data.authentication?.apiKey || "");
|
|
setAuthApiKeyName(data.authentication?.apiKeyName || "X-API-Key");
|
|
setAuthApiKeyLocation(data.authentication?.apiKeyLocation || "header");
|
|
setTimeout(data.options?.timeout?.toString() || "30000");
|
|
setFollowRedirects(data.options?.followRedirects ?? true);
|
|
setRetryCount(data.options?.retryCount?.toString() || "0");
|
|
setRetryDelay(data.options?.retryDelay?.toString() || "1000");
|
|
setHeaders(Object.entries(data.headers || {}).map(([key, value]) => ({ key, value })));
|
|
setQueryParams(Object.entries(data.queryParams || {}).map(([key, value]) => ({ key, value })));
|
|
setExtractPath(data.responseHandling?.extractPath || "");
|
|
setSaveToVariable(data.responseHandling?.saveToVariable || "");
|
|
}, [data]);
|
|
|
|
// 노드 업데이트 함수
|
|
const updateNodeData = useCallback(
|
|
(updates: Partial<HttpRequestActionNodeData>) => {
|
|
updateNode(nodeId, {
|
|
...data,
|
|
...updates,
|
|
});
|
|
},
|
|
[nodeId, data, updateNode]
|
|
);
|
|
|
|
// 표시명 변경
|
|
const handleDisplayNameChange = (value: string) => {
|
|
setDisplayName(value);
|
|
updateNodeData({ displayName: value });
|
|
};
|
|
|
|
// URL 업데이트
|
|
const updateUrl = () => {
|
|
updateNodeData({ url });
|
|
};
|
|
|
|
// 메서드 변경
|
|
const handleMethodChange = (value: HttpRequestActionNodeData["method"]) => {
|
|
setMethod(value);
|
|
updateNodeData({ method: value });
|
|
};
|
|
|
|
// 바디 타입 변경
|
|
const handleBodyTypeChange = (value: HttpRequestActionNodeData["bodyType"]) => {
|
|
setBodyType(value);
|
|
updateNodeData({ bodyType: value });
|
|
};
|
|
|
|
// 바디 업데이트
|
|
const updateBody = () => {
|
|
updateNodeData({ body });
|
|
};
|
|
|
|
// 인증 업데이트
|
|
const updateAuthentication = useCallback(() => {
|
|
updateNodeData({
|
|
authentication: {
|
|
type: authType,
|
|
username: authUsername || undefined,
|
|
password: authPassword || undefined,
|
|
token: authToken || undefined,
|
|
apiKey: authApiKey || undefined,
|
|
apiKeyName: authApiKeyName || undefined,
|
|
apiKeyLocation: authApiKeyLocation,
|
|
},
|
|
});
|
|
}, [authType, authUsername, authPassword, authToken, authApiKey, authApiKeyName, authApiKeyLocation, updateNodeData]);
|
|
|
|
// 옵션 업데이트
|
|
const updateOptions = useCallback(() => {
|
|
updateNodeData({
|
|
options: {
|
|
timeout: parseInt(timeout) || 30000,
|
|
followRedirects,
|
|
retryCount: parseInt(retryCount) || 0,
|
|
retryDelay: parseInt(retryDelay) || 1000,
|
|
},
|
|
});
|
|
}, [timeout, followRedirects, retryCount, retryDelay, updateNodeData]);
|
|
|
|
// 응답 처리 업데이트
|
|
const updateResponseHandling = useCallback(() => {
|
|
updateNodeData({
|
|
responseHandling: {
|
|
extractPath: extractPath || undefined,
|
|
saveToVariable: saveToVariable || undefined,
|
|
},
|
|
});
|
|
}, [extractPath, saveToVariable, updateNodeData]);
|
|
|
|
// 헤더 추가
|
|
const addHeader = () => {
|
|
setHeaders([...headers, { key: "", value: "" }]);
|
|
};
|
|
|
|
// 헤더 삭제
|
|
const removeHeader = (index: number) => {
|
|
const newHeaders = headers.filter((_, i) => i !== index);
|
|
setHeaders(newHeaders);
|
|
const headersObj = Object.fromEntries(newHeaders.filter(h => h.key).map(h => [h.key, h.value]));
|
|
updateNodeData({ headers: headersObj });
|
|
};
|
|
|
|
// 헤더 업데이트
|
|
const updateHeader = (index: number, field: "key" | "value", value: string) => {
|
|
const newHeaders = [...headers];
|
|
newHeaders[index][field] = value;
|
|
setHeaders(newHeaders);
|
|
};
|
|
|
|
// 헤더 저장
|
|
const saveHeaders = () => {
|
|
const headersObj = Object.fromEntries(headers.filter(h => h.key).map(h => [h.key, h.value]));
|
|
updateNodeData({ headers: headersObj });
|
|
};
|
|
|
|
// 쿼리 파라미터 추가
|
|
const addQueryParam = () => {
|
|
setQueryParams([...queryParams, { key: "", value: "" }]);
|
|
};
|
|
|
|
// 쿼리 파라미터 삭제
|
|
const removeQueryParam = (index: number) => {
|
|
const newParams = queryParams.filter((_, i) => i !== index);
|
|
setQueryParams(newParams);
|
|
const paramsObj = Object.fromEntries(newParams.filter(p => p.key).map(p => [p.key, p.value]));
|
|
updateNodeData({ queryParams: paramsObj });
|
|
};
|
|
|
|
// 쿼리 파라미터 업데이트
|
|
const updateQueryParam = (index: number, field: "key" | "value", value: string) => {
|
|
const newParams = [...queryParams];
|
|
newParams[index][field] = value;
|
|
setQueryParams(newParams);
|
|
};
|
|
|
|
// 쿼리 파라미터 저장
|
|
const saveQueryParams = () => {
|
|
const paramsObj = Object.fromEntries(queryParams.filter(p => p.key).map(p => [p.key, p.value]));
|
|
updateNodeData({ queryParams: paramsObj });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4 p-4">
|
|
{/* 표시명 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">표시명</Label>
|
|
<Input
|
|
value={displayName}
|
|
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
|
placeholder="HTTP 요청"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* URL */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">URL *</Label>
|
|
<Input
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
onBlur={updateUrl}
|
|
placeholder="https://api.example.com/endpoint"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* 메서드 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">HTTP 메서드</Label>
|
|
<Select value={method} onValueChange={handleMethodChange}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="GET">GET</SelectItem>
|
|
<SelectItem value="POST">POST</SelectItem>
|
|
<SelectItem value="PUT">PUT</SelectItem>
|
|
<SelectItem value="PATCH">PATCH</SelectItem>
|
|
<SelectItem value="DELETE">DELETE</SelectItem>
|
|
<SelectItem value="HEAD">HEAD</SelectItem>
|
|
<SelectItem value="OPTIONS">OPTIONS</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Tabs defaultValue="headers" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-4">
|
|
<TabsTrigger value="headers" className="text-xs">
|
|
<Globe className="mr-1 h-3 w-3" />
|
|
헤더
|
|
</TabsTrigger>
|
|
<TabsTrigger value="body" className="text-xs">
|
|
<FileJson className="mr-1 h-3 w-3" />
|
|
바디
|
|
</TabsTrigger>
|
|
<TabsTrigger value="auth" className="text-xs">
|
|
<Key className="mr-1 h-3 w-3" />
|
|
인증
|
|
</TabsTrigger>
|
|
<TabsTrigger value="options" className="text-xs">
|
|
<Settings className="mr-1 h-3 w-3" />
|
|
옵션
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 헤더 탭 */}
|
|
<TabsContent value="headers" className="space-y-3 pt-3">
|
|
{/* 쿼리 파라미터 */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">쿼리 파라미터</Label>
|
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={addQueryParam}>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
{queryParams.map((param, index) => (
|
|
<div key={index} className="flex gap-2">
|
|
<Input
|
|
value={param.key}
|
|
onChange={(e) => updateQueryParam(index, "key", e.target.value)}
|
|
onBlur={saveQueryParams}
|
|
placeholder="파라미터명"
|
|
className="h-8 text-sm"
|
|
/>
|
|
<Input
|
|
value={param.value}
|
|
onChange={(e) => updateQueryParam(index, "value", e.target.value)}
|
|
onBlur={saveQueryParams}
|
|
placeholder="값"
|
|
className="h-8 text-sm"
|
|
/>
|
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => removeQueryParam(index)}>
|
|
<Trash2 className="h-3 w-3 text-red-500" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 헤더 */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">요청 헤더</Label>
|
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={addHeader}>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
{headers.map((header, index) => (
|
|
<div key={index} className="flex gap-2">
|
|
<Input
|
|
value={header.key}
|
|
onChange={(e) => updateHeader(index, "key", e.target.value)}
|
|
onBlur={saveHeaders}
|
|
placeholder="헤더명"
|
|
className="h-8 text-sm"
|
|
/>
|
|
<Input
|
|
value={header.value}
|
|
onChange={(e) => updateHeader(index, "value", e.target.value)}
|
|
onBlur={saveHeaders}
|
|
placeholder="값"
|
|
className="h-8 text-sm"
|
|
/>
|
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => removeHeader(index)}>
|
|
<Trash2 className="h-3 w-3 text-red-500" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* 바디 탭 */}
|
|
<TabsContent value="body" className="space-y-3 pt-3">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">바디 타입</Label>
|
|
<Select value={bodyType} onValueChange={handleBodyTypeChange}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">없음</SelectItem>
|
|
<SelectItem value="json">JSON</SelectItem>
|
|
<SelectItem value="form">Form Data</SelectItem>
|
|
<SelectItem value="text">텍스트</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{bodyType !== "none" && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">요청 바디</Label>
|
|
<Textarea
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
onBlur={updateBody}
|
|
placeholder={bodyType === "json" ? '{\n "key": "value"\n}' : "바디 내용"}
|
|
className="min-h-[200px] font-mono text-xs"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<Card className="bg-gray-50">
|
|
<CardContent className="p-3 text-xs text-gray-600">
|
|
<div className="font-medium mb-1">템플릿 변수 사용:</div>
|
|
<code className="block">{"{{sourceData}}"}</code>
|
|
<code className="block">{"{{필드명}}"}</code>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* 인증 탭 */}
|
|
<TabsContent value="auth" className="space-y-3 pt-3">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">인증 방식</Label>
|
|
<Select value={authType} onValueChange={(v: any) => {
|
|
setAuthType(v);
|
|
updateNodeData({ authentication: { ...data.authentication, type: v } });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">인증 없음</SelectItem>
|
|
<SelectItem value="basic">Basic Auth</SelectItem>
|
|
<SelectItem value="bearer">Bearer Token</SelectItem>
|
|
<SelectItem value="apikey">API Key</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{authType === "basic" && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">사용자명</Label>
|
|
<Input
|
|
value={authUsername}
|
|
onChange={(e) => setAuthUsername(e.target.value)}
|
|
onBlur={updateAuthentication}
|
|
placeholder="username"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">비밀번호</Label>
|
|
<Input
|
|
type="password"
|
|
value={authPassword}
|
|
onChange={(e) => setAuthPassword(e.target.value)}
|
|
onBlur={updateAuthentication}
|
|
placeholder="password"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{authType === "bearer" && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">Bearer Token</Label>
|
|
<Input
|
|
value={authToken}
|
|
onChange={(e) => setAuthToken(e.target.value)}
|
|
onBlur={updateAuthentication}
|
|
placeholder="your-token-here"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{authType === "apikey" && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">API Key</Label>
|
|
<Input
|
|
value={authApiKey}
|
|
onChange={(e) => setAuthApiKey(e.target.value)}
|
|
onBlur={updateAuthentication}
|
|
placeholder="your-api-key"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">헤더/파라미터 이름</Label>
|
|
<Input
|
|
value={authApiKeyName}
|
|
onChange={(e) => setAuthApiKeyName(e.target.value)}
|
|
onBlur={updateAuthentication}
|
|
placeholder="X-API-Key"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">위치</Label>
|
|
<Select value={authApiKeyLocation} onValueChange={(v: "header" | "query") => {
|
|
setAuthApiKeyLocation(v);
|
|
updateNodeData({
|
|
authentication: { ...data.authentication, type: authType, apiKeyLocation: v },
|
|
});
|
|
}}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="header">헤더</SelectItem>
|
|
<SelectItem value="query">쿼리 파라미터</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 옵션 탭 */}
|
|
<TabsContent value="options" className="space-y-3 pt-3">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">타임아웃 (ms)</Label>
|
|
<Input
|
|
type="number"
|
|
value={timeout}
|
|
onChange={(e) => setTimeout(e.target.value)}
|
|
onBlur={updateOptions}
|
|
placeholder="30000"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
checked={followRedirects}
|
|
onCheckedChange={(checked) => {
|
|
setFollowRedirects(checked);
|
|
updateNodeData({ options: { ...data.options, followRedirects: checked } });
|
|
}}
|
|
/>
|
|
<Label className="text-xs">리다이렉트 따라가기</Label>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">재시도 횟수</Label>
|
|
<Input
|
|
type="number"
|
|
value={retryCount}
|
|
onChange={(e) => setRetryCount(e.target.value)}
|
|
onBlur={updateOptions}
|
|
placeholder="0"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">재시도 간격 (ms)</Label>
|
|
<Input
|
|
type="number"
|
|
value={retryDelay}
|
|
onChange={(e) => setRetryDelay(e.target.value)}
|
|
onBlur={updateOptions}
|
|
placeholder="1000"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">응답 데이터 추출 경로 (JSONPath)</Label>
|
|
<Input
|
|
value={extractPath}
|
|
onChange={(e) => setExtractPath(e.target.value)}
|
|
onBlur={updateResponseHandling}
|
|
placeholder="data.items"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">결과 저장 변수명</Label>
|
|
<Input
|
|
value={saveToVariable}
|
|
onChange={(e) => setSaveToVariable(e.target.value)}
|
|
onBlur={updateResponseHandling}
|
|
placeholder="apiResponse"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
|