added UI for HTTP block (#2900)
This commit is contained in:
committed by
GitHub
parent
427ad4d9ac
commit
c9431fad6f
@@ -3,7 +3,7 @@ export const baseHelpTooltipContent = {
|
||||
navigationGoal:
|
||||
"Give Skyvern an objective. Make sure to include when the block is complete, when it should self-terminate, and any guardrails. Use {{ parameter_name }} to reference a parameter value",
|
||||
parameters:
|
||||
"Define placeholder values using the “parameters” drop down that you predefine or redefine run-to-run.",
|
||||
'Define placeholder values using the "parameters" drop down that you predefine or redefine run-to-run.',
|
||||
dataExtractionGoal:
|
||||
"Tell Skyvern what data you would like to scrape at the end of your run.",
|
||||
dataSchema: "Specify a format for extracted data in JSON.",
|
||||
@@ -115,6 +115,17 @@ export const helpTooltips = {
|
||||
jsonSchema: "Specify a format for the extracted information from the file",
|
||||
},
|
||||
url: baseHelpTooltipContent,
|
||||
httpRequest: {
|
||||
...baseHelpTooltipContent,
|
||||
url: "The URL to send the HTTP request to. You can use {{ parameter_name }} to reference parameters.",
|
||||
method: "The HTTP method to use for the request.",
|
||||
headers: "HTTP headers to include with the request as JSON object.",
|
||||
body: "Request body as JSON object. Only used for POST, PUT, PATCH methods.",
|
||||
timeout: "Request timeout in seconds.",
|
||||
followRedirects: "Whether to automatically follow HTTP redirects.",
|
||||
continueOnFailure:
|
||||
"Allow the workflow to continue if the HTTP request fails.",
|
||||
},
|
||||
};
|
||||
|
||||
export const placeholders = {
|
||||
@@ -159,4 +170,11 @@ export const placeholders = {
|
||||
...basePlaceholderContent,
|
||||
url: "(required) Navigate to this URL: https://...",
|
||||
},
|
||||
httpRequest: {
|
||||
...basePlaceholderContent,
|
||||
url: "https://api.example.com/endpoint",
|
||||
headers:
|
||||
'{\n "Content-Type": "application/json",\n "Authorization": "Bearer {{ token }}"\n}',
|
||||
body: '{\n "key": "value",\n "parameter": "{{ parameter_name }}"\n}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useState } from "react";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
ReloadIcon,
|
||||
CodeIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type Props = {
|
||||
onImport: (data: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: string;
|
||||
body: string;
|
||||
timeout: number;
|
||||
followRedirects: boolean;
|
||||
}) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const curlExamples = [
|
||||
{
|
||||
name: "GET Request",
|
||||
curl: `curl -X GET "https://api.example.com/users" \\
|
||||
-H "Authorization: Bearer token123" \\
|
||||
-H "Accept: application/json"`,
|
||||
},
|
||||
{
|
||||
name: "POST JSON",
|
||||
curl: `curl -X POST "https://api.example.com/users" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer token123" \\
|
||||
-d '{"name": "John Doe", "email": "john@example.com"}'`,
|
||||
},
|
||||
{
|
||||
name: "PUT Request",
|
||||
curl: `curl -X PUT "https://api.example.com/users/123" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"name": "Jane Doe"}'`,
|
||||
},
|
||||
];
|
||||
|
||||
export function CurlImportDialog({ onImport, children }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [curlCommand, setCurlCommand] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<{
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
} | null>(null);
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!curlCommand.trim()) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Please enter a curl command",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const client = await getClient(credentialGetter);
|
||||
const response = await client.post("/utilities/curl-to-http", {
|
||||
curl_command: curlCommand.trim(),
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
|
||||
onImport({
|
||||
method: data.method || "GET",
|
||||
url: data.url || "",
|
||||
headers: JSON.stringify(data.headers || {}, null, 2),
|
||||
body: JSON.stringify(data.body || {}, null, 2),
|
||||
timeout: data.timeout || 30,
|
||||
followRedirects: data.follow_redirects ?? true,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Curl command imported successfully",
|
||||
variant: "success",
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
setCurlCommand("");
|
||||
setPreviewData(null);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
(
|
||||
error as {
|
||||
response?: { data?: { detail?: string } };
|
||||
message?: string;
|
||||
}
|
||||
).response?.data?.detail ||
|
||||
(error as { message?: string }).message ||
|
||||
"Failed to parse curl command";
|
||||
toast({
|
||||
title: "Import Failed",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!curlCommand.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const client = await getClient(credentialGetter);
|
||||
const response = await client.post("/utilities/curl-to-http", {
|
||||
curl_command: curlCommand.trim(),
|
||||
});
|
||||
setPreviewData(response.data);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
(
|
||||
error as {
|
||||
response?: { data?: { detail?: string } };
|
||||
message?: string;
|
||||
}
|
||||
).response?.data?.detail ||
|
||||
(error as { message?: string }).message ||
|
||||
"Failed to parse curl command";
|
||||
toast({
|
||||
title: "Preview Failed",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
setPreviewData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyExample = (example: string) => {
|
||||
navigator.clipboard.writeText(example);
|
||||
toast({
|
||||
title: "Copied",
|
||||
description: "Example copied to clipboard",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CodeIcon className="h-5 w-5" />
|
||||
Import from cURL
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Paste your curl command below and we'll automatically populate the
|
||||
HTTP request fields.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Left side - Input */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">
|
||||
cURL Command
|
||||
</label>
|
||||
<Textarea
|
||||
placeholder="Paste your curl command here..."
|
||||
value={curlCommand}
|
||||
onChange={(e) => setCurlCommand(e.target.value)}
|
||||
className="min-h-[200px] font-mono text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreview}
|
||||
disabled={loading || !curlCommand.trim()}
|
||||
>
|
||||
{loading && (
|
||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={loading || !curlCommand.trim()}
|
||||
size="sm"
|
||||
>
|
||||
{loading && (
|
||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Import Request
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<strong>Supported:</strong> -X, -H, -d, --data, --json, -u,
|
||||
--user, --cookie, --referer, and more.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{/* Right side - Examples and Preview */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium">Examples</h4>
|
||||
<div className="space-y-2">
|
||||
{curlExamples.map((example, index) => (
|
||||
<div key={index} className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium">
|
||||
{example.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyExample(example.curl)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<CopyIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-xs text-slate-400">
|
||||
{example.curl}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{previewData && (
|
||||
<div>
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
Preview
|
||||
</h4>
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{previewData.method}
|
||||
</Badge>
|
||||
<span className="text-xs text-slate-400">
|
||||
{previewData.url}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{previewData.headers &&
|
||||
Object.keys(previewData.headers).length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium">Headers:</div>
|
||||
<div className="space-y-1 text-xs text-slate-400">
|
||||
{Object.entries(previewData.headers).map(
|
||||
([key, value]) => (
|
||||
<div key={key} className="font-mono">
|
||||
{key}: {value as string}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewData.body != null &&
|
||||
(() => {
|
||||
try {
|
||||
const bodyStr = JSON.stringify(
|
||||
previewData.body,
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium">
|
||||
Body:
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap text-xs text-slate-400">
|
||||
{bodyStr || "{}"}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium">
|
||||
Body:
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap text-xs text-slate-400">
|
||||
{"{}"}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={loading || !curlCommand.trim()}
|
||||
>
|
||||
{loading && <ReloadIcon className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Import Request
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
||||
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
|
||||
import {
|
||||
Handle,
|
||||
NodeProps,
|
||||
Position,
|
||||
useEdges,
|
||||
useNodes,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { useState } from "react";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
import { NodeActionMenu } from "../NodeActionMenu";
|
||||
import type { HttpRequestNode as HttpRequestNodeType } from "./types";
|
||||
import { HelpTooltip } from "@/components/HelpTooltip";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { placeholders, helpTooltips } from "../../helpContent";
|
||||
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
|
||||
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
|
||||
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
|
||||
import { AppNode } from "..";
|
||||
import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils";
|
||||
import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect";
|
||||
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
|
||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeIcon, PlusIcon, MagicWandIcon } from "@radix-ui/react-icons";
|
||||
import { CurlImportDialog } from "./CurlImportDialog";
|
||||
import { QuickHeadersDialog } from "./QuickHeadersDialog";
|
||||
import { MethodBadge, UrlValidator, RequestPreview } from "./HttpUtils";
|
||||
|
||||
const httpMethods = [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"PATCH",
|
||||
"HEAD",
|
||||
"OPTIONS",
|
||||
];
|
||||
|
||||
const urlTooltip =
|
||||
"The URL to send the HTTP request to. You can use {{ parameter_name }} to reference parameters.";
|
||||
const methodTooltip = "The HTTP method to use for the request.";
|
||||
const headersTooltip =
|
||||
"HTTP headers to include with the request as JSON object.";
|
||||
const bodyTooltip =
|
||||
"Request body as JSON object. Only used for POST, PUT, PATCH methods.";
|
||||
const timeoutTooltip = "Request timeout in seconds.";
|
||||
const followRedirectsTooltip =
|
||||
"Whether to automatically follow HTTP redirects.";
|
||||
|
||||
function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
const { editable } = data;
|
||||
const [label, setLabel] = useNodeLabelChangeHandler({
|
||||
id,
|
||||
initialValue: data.label,
|
||||
});
|
||||
const [inputs, setInputs] = useState({
|
||||
method: data.method,
|
||||
url: data.url,
|
||||
headers: data.headers,
|
||||
body: data.body,
|
||||
timeout: data.timeout,
|
||||
followRedirects: data.followRedirects,
|
||||
continueOnFailure: data.continueOnFailure,
|
||||
});
|
||||
const deleteNodeCallback = useDeleteNodeCallback();
|
||||
|
||||
const nodes = useNodes<AppNode>();
|
||||
const edges = useEdges();
|
||||
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
|
||||
|
||||
function handleChange(key: string, value: unknown) {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
setInputs({ ...inputs, [key]: value });
|
||||
updateNodeData(id, { [key]: value });
|
||||
}
|
||||
|
||||
const handleCurlImport = (importedData: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: string;
|
||||
body: string;
|
||||
timeout: number;
|
||||
followRedirects: boolean;
|
||||
}) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
method: importedData.method,
|
||||
url: importedData.url,
|
||||
headers: importedData.headers,
|
||||
body: importedData.body,
|
||||
timeout: importedData.timeout,
|
||||
followRedirects: importedData.followRedirects,
|
||||
};
|
||||
setInputs(newInputs);
|
||||
updateNodeData(id, {
|
||||
method: importedData.method,
|
||||
url: importedData.url,
|
||||
headers: importedData.headers,
|
||||
body: importedData.body,
|
||||
timeout: importedData.timeout,
|
||||
followRedirects: importedData.followRedirects,
|
||||
});
|
||||
};
|
||||
|
||||
const handleQuickHeaders = (headers: Record<string, string>) => {
|
||||
try {
|
||||
const existingHeaders = JSON.parse(inputs.headers || "{}");
|
||||
const mergedHeaders = { ...existingHeaders, ...headers };
|
||||
const newHeadersString = JSON.stringify(mergedHeaders, null, 2);
|
||||
handleChange("headers", newHeadersString);
|
||||
} catch (error) {
|
||||
// If existing headers are invalid, just use the new ones
|
||||
const newHeadersString = JSON.stringify(headers, null, 2);
|
||||
handleChange("headers", newHeadersString);
|
||||
}
|
||||
};
|
||||
|
||||
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
|
||||
|
||||
const showBodyEditor =
|
||||
inputs.method !== "GET" &&
|
||||
inputs.method !== "HEAD" &&
|
||||
inputs.method !== "DELETE";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="a"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="b"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<div className="w-[36rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
|
||||
<header className="flex h-[2.75rem] justify-between">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex h-[2.75rem] w-[2.75rem] items-center justify-center rounded border border-slate-600">
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.HttpRequest}
|
||||
className="size-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<EditableNodeTitle
|
||||
value={label}
|
||||
editable={editable}
|
||||
onChange={setLabel}
|
||||
titleClassName="text-base"
|
||||
inputClassName="text-base"
|
||||
/>
|
||||
<span className="text-xs text-slate-400">HTTP Request Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* Quick Action Buttons */}
|
||||
<CurlImportDialog onImport={handleCurlImport}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
disabled={!editable}
|
||||
>
|
||||
<CodeIcon className="mr-1 h-3 w-3" />
|
||||
Import cURL
|
||||
</Button>
|
||||
</CurlImportDialog>
|
||||
|
||||
<NodeActionMenu
|
||||
onDelete={() => {
|
||||
deleteNodeCallback(id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Method and URL Section */}
|
||||
<div className="flex gap-4">
|
||||
<div className="w-32 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">Method</Label>
|
||||
<HelpTooltip content={methodTooltip} />
|
||||
</div>
|
||||
<Select
|
||||
value={inputs.method}
|
||||
onValueChange={(value) => handleChange("method", value)}
|
||||
disabled={!editable}
|
||||
>
|
||||
<SelectTrigger className="nopan text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<MethodBadge method={inputs.method} />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{httpMethods.map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
<div className="flex items-center gap-2">
|
||||
<MethodBadge method={method} />
|
||||
{method}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">URL</Label>
|
||||
<HelpTooltip content={urlTooltip} />
|
||||
</div>
|
||||
{isFirstWorkflowBlock ? (
|
||||
<div className="flex justify-end text-xs text-slate-400">
|
||||
Tip: Use the {"+"} button to add parameters!
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<WorkflowBlockInputTextarea
|
||||
nodeId={id}
|
||||
onChange={(value) => {
|
||||
handleChange("url", value);
|
||||
}}
|
||||
value={inputs.url}
|
||||
placeholder={placeholders["httpRequest"]["url"]}
|
||||
className="nopan text-xs"
|
||||
/>
|
||||
<UrlValidator url={inputs.url} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Headers Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">Headers</Label>
|
||||
<HelpTooltip content={headersTooltip} />
|
||||
</div>
|
||||
<QuickHeadersDialog onAdd={handleQuickHeaders}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={!editable}
|
||||
>
|
||||
<PlusIcon className="mr-1 h-3 w-3" />
|
||||
Quick Headers
|
||||
</Button>
|
||||
</QuickHeadersDialog>
|
||||
</div>
|
||||
<CodeEditor
|
||||
className="w-full"
|
||||
language="json"
|
||||
value={inputs.headers}
|
||||
onChange={(value) => {
|
||||
handleChange("headers", value || "{}");
|
||||
}}
|
||||
readOnly={!editable}
|
||||
minHeight="80px"
|
||||
maxHeight="160px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body Section */}
|
||||
{showBodyEditor && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">Body</Label>
|
||||
<HelpTooltip content={bodyTooltip} />
|
||||
</div>
|
||||
<CodeEditor
|
||||
className="w-full"
|
||||
language="json"
|
||||
value={inputs.body}
|
||||
onChange={(value) => {
|
||||
handleChange("body", value || "{}");
|
||||
}}
|
||||
readOnly={!editable}
|
||||
minHeight="100px"
|
||||
maxHeight="200px"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Request Preview */}
|
||||
<RequestPreview
|
||||
method={inputs.method}
|
||||
url={inputs.url}
|
||||
headers={inputs.headers}
|
||||
body={inputs.body}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="advanced" className="border-b-0">
|
||||
<AccordionTrigger className="py-0">
|
||||
Advanced Settings
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pl-6 pr-1 pt-1">
|
||||
<div className="space-y-4">
|
||||
<ParametersMultiSelect
|
||||
availableOutputParameters={outputParameterKeys}
|
||||
parameters={data.parameterKeys}
|
||||
onParametersChange={(parameterKeys) => {
|
||||
updateNodeData(id, { parameterKeys });
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-32 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">Timeout</Label>
|
||||
<HelpTooltip content={timeoutTooltip} />
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="300"
|
||||
value={inputs.timeout}
|
||||
onChange={(e) =>
|
||||
handleChange("timeout", parseInt(e.target.value) || 30)
|
||||
}
|
||||
className="nopan text-xs"
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">
|
||||
Follow Redirects
|
||||
</Label>
|
||||
<HelpTooltip content={followRedirectsTooltip} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">
|
||||
Automatically follow HTTP redirects
|
||||
</span>
|
||||
<Switch
|
||||
checked={inputs.followRedirects}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("followRedirects", checked)
|
||||
}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">
|
||||
Continue on Failure
|
||||
</Label>
|
||||
<HelpTooltip
|
||||
content={
|
||||
helpTooltips["httpRequest"]["continueOnFailure"]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Switch
|
||||
checked={inputs.continueOnFailure}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("continueOnFailure", checked)
|
||||
}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* Tips Section */}
|
||||
<div className="rounded-md bg-slate-800/50 p-3">
|
||||
<div className="space-y-2 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<MagicWandIcon className="h-3 w-3" />
|
||||
<span className="font-medium">Quick Tips:</span>
|
||||
</div>
|
||||
<ul className="ml-5 list-disc space-y-1">
|
||||
<li>
|
||||
Use "Import cURL" to quickly convert API documentation examples
|
||||
</li>
|
||||
<li>
|
||||
Use "Quick Headers" in the headers section to add common
|
||||
authentication and content headers
|
||||
</li>
|
||||
<li>
|
||||
The request will return response data including status, headers,
|
||||
and body
|
||||
</li>
|
||||
<li>Reference response data in later blocks with parameters</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { HttpRequestNode };
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CopyIcon,
|
||||
CheckIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircledIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
// HTTP Method Badge Component
|
||||
export function MethodBadge({
|
||||
method,
|
||||
className,
|
||||
}: {
|
||||
method: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const getMethodStyle = (method: string) => {
|
||||
switch (method.toUpperCase()) {
|
||||
case "GET":
|
||||
return "bg-green-100 text-green-800 border-green-300 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800";
|
||||
case "POST":
|
||||
return "bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800";
|
||||
case "PUT":
|
||||
return "bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800";
|
||||
case "DELETE":
|
||||
return "bg-red-100 text-red-800 border-red-300 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800";
|
||||
case "PATCH":
|
||||
return "bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-900/20 dark:text-purple-400 dark:border-purple-800";
|
||||
case "HEAD":
|
||||
return "bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-900/20 dark:text-gray-400 dark:border-gray-800";
|
||||
case "OPTIONS":
|
||||
return "bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-900/20 dark:text-cyan-400 dark:border-cyan-800";
|
||||
default:
|
||||
return "bg-slate-100 text-slate-800 border-slate-300 dark:bg-slate-900/20 dark:text-slate-400 dark:border-slate-800";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border font-mono text-xs font-bold",
|
||||
getMethodStyle(method),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{method}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// URL Validation Component
|
||||
export function UrlValidator({ url }: { url: string }) {
|
||||
const isValidUrl = (urlString: string) => {
|
||||
if (!urlString.trim()) return { valid: false, message: "URL is required" };
|
||||
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
return { valid: false, message: "URL must use HTTP or HTTPS protocol" };
|
||||
}
|
||||
return { valid: true, message: "Valid URL" };
|
||||
} catch {
|
||||
return { valid: false, message: "Invalid URL format" };
|
||||
}
|
||||
};
|
||||
|
||||
const validation = isValidUrl(url);
|
||||
|
||||
if (!url.trim()) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs",
|
||||
validation.valid
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
{validation.valid ? (
|
||||
<CheckCircledIcon className="h-3 w-3" />
|
||||
) : (
|
||||
<ExclamationTriangleIcon className="h-3 w-3" />
|
||||
)}
|
||||
<span>{validation.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Copy to Curl Component
|
||||
export function CopyToCurlButton({
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
className,
|
||||
}: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: string;
|
||||
body: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const generateCurlCommand = () => {
|
||||
let curl = `curl -X ${method.toUpperCase()}`;
|
||||
|
||||
if (url) {
|
||||
curl += ` "${url}"`;
|
||||
}
|
||||
|
||||
// Parse and add headers
|
||||
try {
|
||||
const parsedHeaders = JSON.parse(headers || "{}");
|
||||
Object.entries(parsedHeaders).forEach(([key, value]) => {
|
||||
curl += ` \\\n -H "${key}: ${value}"`;
|
||||
});
|
||||
} catch (error) {
|
||||
// If headers can't be parsed, skip them
|
||||
}
|
||||
|
||||
// Add body for non-GET requests
|
||||
if (["POST", "PUT", "PATCH"].includes(method.toUpperCase()) && body) {
|
||||
try {
|
||||
const parsedBody = JSON.parse(body);
|
||||
curl += ` \\\n -d '${JSON.stringify(parsedBody)}'`;
|
||||
} catch (error) {
|
||||
// If body can't be parsed, add it as-is
|
||||
curl += ` \\\n -d '${body}'`;
|
||||
}
|
||||
}
|
||||
|
||||
return curl;
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
const curlCommand = generateCurlCommand();
|
||||
await navigator.clipboard.writeText(curlCommand);
|
||||
setCopied(true);
|
||||
toast({
|
||||
title: "Copied!",
|
||||
description: "cURL command copied to clipboard",
|
||||
});
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to copy cURL command",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className={cn("h-8 px-2", className)}
|
||||
disabled={!url}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<CopyIcon className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{copied ? "Copied!" : "Copy cURL"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Request Preview Component
|
||||
export function RequestPreview({
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
}: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: string;
|
||||
body: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const hasContent = method && url;
|
||||
|
||||
if (!hasContent) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-slate-50 p-3 dark:bg-slate-900/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MethodBadge method={method} />
|
||||
<span className="font-mono text-sm text-slate-600 dark:text-slate-400">
|
||||
{url || "No URL specified"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
{expanded ? "Hide" : "Show"} Details
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{/* Headers */}
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium">Headers:</div>
|
||||
<pre className="overflow-x-auto rounded bg-slate-100 p-2 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
{headers || "{}"}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Body (only for POST, PUT, PATCH) */}
|
||||
{["POST", "PUT", "PATCH"].includes(method.toUpperCase()) && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium">Body:</div>
|
||||
<pre className="overflow-x-auto rounded bg-slate-100 p-2 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
{body || "{}"}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type Props = {
|
||||
onAdd: (headers: Record<string, string>) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const commonHeaders = [
|
||||
{
|
||||
name: "Content-Type",
|
||||
value: "application/json",
|
||||
description: "JSON content",
|
||||
},
|
||||
{
|
||||
name: "Content-Type",
|
||||
value: "application/x-www-form-urlencoded",
|
||||
description: "Form data",
|
||||
},
|
||||
{
|
||||
name: "Authorization",
|
||||
value: "Bearer YOUR_TOKEN",
|
||||
description: "Bearer token auth",
|
||||
},
|
||||
{
|
||||
name: "Authorization",
|
||||
value: "Basic YOUR_CREDENTIALS",
|
||||
description: "Basic auth",
|
||||
},
|
||||
{ name: "User-Agent", value: "Skyvern/1.0", description: "User agent" },
|
||||
{
|
||||
name: "Accept",
|
||||
value: "application/json",
|
||||
description: "Accept JSON response",
|
||||
},
|
||||
{ name: "Accept", value: "*/*", description: "Accept any response" },
|
||||
{ name: "X-API-Key", value: "YOUR_API_KEY", description: "API key header" },
|
||||
{ name: "Cache-Control", value: "no-cache", description: "No cache" },
|
||||
{
|
||||
name: "Referer",
|
||||
value: "https://example.com",
|
||||
description: "Referer header",
|
||||
},
|
||||
];
|
||||
|
||||
export function QuickHeadersDialog({ onAdd, children }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedHeaders, setSelectedHeaders] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [customKey, setCustomKey] = useState("");
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
||||
const handleAddCustomHeader = () => {
|
||||
if (customKey.trim() && customValue.trim()) {
|
||||
setSelectedHeaders((prev) => ({
|
||||
...prev,
|
||||
[customKey.trim()]: customValue.trim(),
|
||||
}));
|
||||
setCustomKey("");
|
||||
setCustomValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleHeader = (name: string, value: string) => {
|
||||
setSelectedHeaders((prev) => {
|
||||
const newHeaders = { ...prev };
|
||||
if (newHeaders[name] === value) {
|
||||
delete newHeaders[name];
|
||||
} else {
|
||||
newHeaders[name] = value;
|
||||
}
|
||||
return newHeaders;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddHeaders = () => {
|
||||
if (Object.keys(selectedHeaders).length > 0) {
|
||||
onAdd(selectedHeaders);
|
||||
setSelectedHeaders({});
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Add Common Headers
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Quickly add common HTTP headers to your request.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Common Headers */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium">Common Headers</h4>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{commonHeaders.map((header, index) => {
|
||||
const isSelected =
|
||||
selectedHeaders[header.name] === header.value;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 ${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleToggleHeader(header.name, header.value)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{header.name}
|
||||
</Badge>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{header.value}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Selected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{header.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Header */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium">Custom Header</h4>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="custom-key" className="text-xs">
|
||||
Header Name
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-key"
|
||||
placeholder="X-Custom-Header"
|
||||
value={customKey}
|
||||
onChange={(e) => setCustomKey(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="custom-value" className="text-xs">
|
||||
Header Value
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-value"
|
||||
placeholder="custom-value"
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddCustomHeader}
|
||||
disabled={!customKey.trim() || !customValue.trim()}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Headers Preview */}
|
||||
{Object.keys(selectedHeaders).length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium">
|
||||
Selected Headers ({Object.keys(selectedHeaders).length})
|
||||
</h4>
|
||||
<div className="rounded-lg border bg-slate-50 p-3 dark:bg-slate-800">
|
||||
<pre className="text-xs text-slate-600 dark:text-slate-400">
|
||||
{JSON.stringify(selectedHeaders, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddHeaders}
|
||||
disabled={Object.keys(selectedHeaders).length === 0}
|
||||
>
|
||||
Add Headers ({Object.keys(selectedHeaders).length})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { HttpRequestNode } from "./HttpRequestNode";
|
||||
export type {
|
||||
HttpRequestNode as HttpRequestNodeType,
|
||||
HttpRequestNodeData,
|
||||
} from "./types";
|
||||
export { httpRequestNodeDefaultData, isHttpRequestNode } from "./types";
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Node } from "@xyflow/react";
|
||||
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
|
||||
import { NodeBaseData } from "../types";
|
||||
|
||||
export type HttpRequestNodeData = NodeBaseData & {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: string; // JSON string representation of headers
|
||||
body: string; // JSON string representation of body
|
||||
timeout: number;
|
||||
followRedirects: boolean;
|
||||
parameterKeys: Array<string>;
|
||||
};
|
||||
|
||||
export type HttpRequestNode = Node<HttpRequestNodeData, "http_request">;
|
||||
|
||||
export const httpRequestNodeDefaultData: HttpRequestNodeData = {
|
||||
debuggable: debuggableWorkflowBlockTypes.has("http_request"),
|
||||
label: "",
|
||||
continueOnFailure: false,
|
||||
method: "GET",
|
||||
url: "",
|
||||
headers: "{}",
|
||||
body: "{}",
|
||||
timeout: 30,
|
||||
followRedirects: true,
|
||||
parameterKeys: [],
|
||||
editable: true,
|
||||
model: null,
|
||||
};
|
||||
|
||||
export function isHttpRequestNode(node: Node): node is HttpRequestNode {
|
||||
return node.type === "http_request";
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
EnvelopeClosedIcon,
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
ListBulletIcon,
|
||||
LockOpen1Icon,
|
||||
StopwatchIcon,
|
||||
@@ -79,6 +80,9 @@ function WorkflowBlockIcon({ workflowBlockType, className }: Props) {
|
||||
case "goto_url": {
|
||||
return <ExternalLinkIcon className={className} />;
|
||||
}
|
||||
case "http_request": {
|
||||
return <GlobeIcon className={className} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ import { Taskv2Node } from "./Taskv2Node/types";
|
||||
import { Taskv2Node as Taskv2NodeComponent } from "./Taskv2Node/Taskv2Node";
|
||||
import { URLNode } from "./URLNode/types";
|
||||
import { URLNode as URLNodeComponent } from "./URLNode/URLNode";
|
||||
import { HttpRequestNode } from "./HttpRequestNode/types";
|
||||
import { HttpRequestNode as HttpRequestNodeComponent } from "./HttpRequestNode/HttpRequestNode";
|
||||
|
||||
export type UtilityNode = StartNode | NodeAdderNode;
|
||||
|
||||
@@ -63,7 +65,8 @@ export type WorkflowBlockNode =
|
||||
| FileDownloadNode
|
||||
| PDFParserNode
|
||||
| Taskv2Node
|
||||
| URLNode;
|
||||
| URLNode
|
||||
| HttpRequestNode;
|
||||
|
||||
export function isUtilityNode(node: AppNode): node is UtilityNode {
|
||||
return node.type === "nodeAdder" || node.type === "start";
|
||||
@@ -97,4 +100,5 @@ export const nodeTypes = {
|
||||
pdfParser: memo(PDFParserNodeComponent),
|
||||
taskv2: memo(Taskv2NodeComponent),
|
||||
url: memo(URLNodeComponent),
|
||||
http_request: memo(HttpRequestNodeComponent),
|
||||
} as const;
|
||||
|
||||
@@ -52,4 +52,5 @@ export const workflowBlockTitle: {
|
||||
pdf_parser: "PDF Parser",
|
||||
task_v2: "Task v2",
|
||||
goto_url: "Go to URL",
|
||||
http_request: "HTTP Request",
|
||||
};
|
||||
|
||||
@@ -43,6 +43,17 @@ const nodeLibraryItems: Array<{
|
||||
title: "Navigation Block",
|
||||
description: "Navigate on the page",
|
||||
},
|
||||
{
|
||||
nodeType: "task",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.Task}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Task Block",
|
||||
description: "Complete multi-step browser automation tasks",
|
||||
},
|
||||
{
|
||||
nodeType: "taskv2",
|
||||
icon: (
|
||||
@@ -74,141 +85,7 @@ const nodeLibraryItems: Array<{
|
||||
/>
|
||||
),
|
||||
title: "Extraction Block",
|
||||
description: "Extract data from the page",
|
||||
},
|
||||
{
|
||||
nodeType: "validation",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.Validation}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Validation Block",
|
||||
description: "Validate the state of the workflow or terminate",
|
||||
},
|
||||
{
|
||||
nodeType: "task",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.Task}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Task Block",
|
||||
description: "Takes actions or extracts information",
|
||||
},
|
||||
|
||||
{
|
||||
nodeType: "url",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.URL}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Go to URL Block",
|
||||
description: "Navigates to a URL",
|
||||
},
|
||||
{
|
||||
nodeType: "textPrompt",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.TextPrompt}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Text Prompt Block",
|
||||
description: "Generates AI response",
|
||||
},
|
||||
{
|
||||
nodeType: "sendEmail",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.SendEmail}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Send Email Block",
|
||||
description: "Sends an email",
|
||||
},
|
||||
{
|
||||
nodeType: "loop",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.ForLoop}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "For Loop Block",
|
||||
description: "Repeats nested elements",
|
||||
},
|
||||
{
|
||||
nodeType: "codeBlock",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.Code}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Code Block",
|
||||
description: "Executes Python code",
|
||||
},
|
||||
{
|
||||
nodeType: "fileParser",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.FileURLParser}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "File Parser Block",
|
||||
description: "Downloads and parses a file",
|
||||
},
|
||||
{
|
||||
nodeType: "pdfParser",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.PDFParser}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "PDF Parser Block",
|
||||
description: "Downloads and parses a PDF file with an optional data schema",
|
||||
},
|
||||
// disabled
|
||||
// {
|
||||
// nodeType: "download",
|
||||
// icon: (
|
||||
// <WorkflowBlockIcon
|
||||
// workflowBlockType={WorkflowBlockTypes.DownloadToS3}
|
||||
// className="size-6"
|
||||
// />
|
||||
// ),
|
||||
// title: "Download Block",
|
||||
// description: "Downloads a file from S3",
|
||||
// },
|
||||
{
|
||||
nodeType: "fileUpload",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.FileUpload}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "File Upload Block",
|
||||
description: "Uploads downloaded files to where you want.",
|
||||
},
|
||||
{
|
||||
nodeType: "fileDownload",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.FileDownload}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "File Download Block",
|
||||
description: "Download a file",
|
||||
description: "Extract data from a webpage",
|
||||
},
|
||||
{
|
||||
nodeType: "wait",
|
||||
@@ -219,7 +96,150 @@ const nodeLibraryItems: Array<{
|
||||
/>
|
||||
),
|
||||
title: "Wait Block",
|
||||
description: "Wait for some time",
|
||||
description: "Wait for a specified amount of time",
|
||||
},
|
||||
{
|
||||
nodeType: "validation",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.Validation}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Validation Block",
|
||||
description: "Validate completion criteria",
|
||||
},
|
||||
{
|
||||
nodeType: "url",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.URL}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Go to URL Block",
|
||||
description: "Navigate to a specific URL",
|
||||
},
|
||||
{
|
||||
nodeType: "http_request",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.HttpRequest}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "HTTP Request Block",
|
||||
description: "Make HTTP API calls",
|
||||
},
|
||||
{
|
||||
nodeType: "textPrompt",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.TextPrompt}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Text Prompt Block",
|
||||
description: "Process text with LLM",
|
||||
},
|
||||
{
|
||||
nodeType: "codeBlock",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.Code}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Code Block",
|
||||
description: "Execute custom Python code",
|
||||
},
|
||||
{
|
||||
nodeType: "fileDownload",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.FileDownload}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "File Download Block",
|
||||
description: "Download files from a website",
|
||||
},
|
||||
{
|
||||
nodeType: "loop",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.ForLoop}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Loop Block",
|
||||
description: "Repeat blocks for each item",
|
||||
},
|
||||
{
|
||||
nodeType: "sendEmail",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.SendEmail}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Send Email Block",
|
||||
description: "Send email notifications",
|
||||
},
|
||||
{
|
||||
nodeType: "fileParser",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.FileURLParser}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "File Parser Block",
|
||||
description: "Parse data from files",
|
||||
},
|
||||
{
|
||||
nodeType: "upload",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.UploadToS3}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Upload to S3 Block",
|
||||
description: "Upload files to AWS S3",
|
||||
},
|
||||
{
|
||||
nodeType: "fileUpload",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.FileUpload}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "File Upload Block",
|
||||
description: "Upload files to storage",
|
||||
},
|
||||
{
|
||||
nodeType: "download",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.DownloadToS3}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "Download to S3 Block",
|
||||
description: "Download files to AWS S3",
|
||||
},
|
||||
{
|
||||
nodeType: "pdfParser",
|
||||
icon: (
|
||||
<WorkflowBlockIcon
|
||||
workflowBlockType={WorkflowBlockTypes.PDFParser}
|
||||
className="size-6"
|
||||
/>
|
||||
),
|
||||
title: "PDF Parser Block",
|
||||
description: "Extract data from PDF files",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
WorkflowBlockTypes,
|
||||
WorkflowParameterTypes,
|
||||
WorkflowParameterValueType,
|
||||
debuggableWorkflowBlockTypes,
|
||||
type AWSSecretParameter,
|
||||
type OutputParameter,
|
||||
type Parameter,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
Taskv2BlockYAML,
|
||||
URLBlockYAML,
|
||||
FileUploadBlockYAML,
|
||||
HttpRequestBlockYAML,
|
||||
} from "../types/workflowYamlTypes";
|
||||
import {
|
||||
EMAIL_BLOCK_SENDER,
|
||||
@@ -99,7 +101,7 @@ import {
|
||||
import { taskv2NodeDefaultData } from "./nodes/Taskv2Node/types";
|
||||
import { urlNodeDefaultData } from "./nodes/URLNode/types";
|
||||
import { fileUploadNodeDefaultData } from "./nodes/FileUploadNode/types";
|
||||
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
|
||||
import { httpRequestNodeDefaultData } from "./nodes/HttpRequestNode/types";
|
||||
|
||||
export const NEW_NODE_LABEL_PREFIX = "block_";
|
||||
|
||||
@@ -529,6 +531,23 @@ function convertToNode(
|
||||
},
|
||||
};
|
||||
}
|
||||
case "http_request": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "http_request",
|
||||
data: {
|
||||
...commonData,
|
||||
method: block.method,
|
||||
url: block.url ?? "",
|
||||
headers: JSON.stringify(block.headers || {}, null, 2),
|
||||
body: JSON.stringify(block.body || {}, null, 2),
|
||||
timeout: block.timeout,
|
||||
followRedirects: block.follow_redirects,
|
||||
parameterKeys: block.parameters.map((p) => p.key),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -951,6 +970,17 @@ function createNode(
|
||||
},
|
||||
};
|
||||
}
|
||||
case "http_request": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "http_request",
|
||||
data: {
|
||||
...httpRequestNodeDefaultData,
|
||||
label,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1233,6 +1263,22 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML {
|
||||
url: node.data.url,
|
||||
};
|
||||
}
|
||||
case "http_request": {
|
||||
return {
|
||||
...base,
|
||||
block_type: "http_request",
|
||||
method: node.data.method,
|
||||
url: node.data.url,
|
||||
headers: JSONParseSafe(node.data.headers) as Record<
|
||||
string,
|
||||
string
|
||||
> | null,
|
||||
body: JSONParseSafe(node.data.body) as Record<string, unknown> | null,
|
||||
timeout: node.data.timeout,
|
||||
follow_redirects: node.data.followRedirects,
|
||||
parameter_keys: node.data.parameterKeys,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
throw new Error("Invalid node type for getWorkflowBlock");
|
||||
}
|
||||
@@ -1986,6 +2032,20 @@ function convertBlocksToBlockYAML(
|
||||
};
|
||||
return blockYaml;
|
||||
}
|
||||
case "http_request": {
|
||||
const blockYaml: HttpRequestBlockYAML = {
|
||||
...base,
|
||||
block_type: "http_request",
|
||||
method: block.method,
|
||||
url: block.url,
|
||||
headers: block.headers,
|
||||
body: block.body,
|
||||
timeout: block.timeout,
|
||||
follow_redirects: block.follow_redirects,
|
||||
parameter_keys: block.parameters.map((p) => p.key),
|
||||
};
|
||||
return blockYaml;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,7 +190,8 @@ export type WorkflowBlock =
|
||||
| FileDownloadBlock
|
||||
| PDFParserBlock
|
||||
| Taskv2Block
|
||||
| URLBlock;
|
||||
| URLBlock
|
||||
| HttpRequestBlock;
|
||||
|
||||
export const WorkflowBlockTypes = {
|
||||
Task: "task",
|
||||
@@ -212,6 +213,7 @@ export const WorkflowBlockTypes = {
|
||||
PDFParser: "pdf_parser",
|
||||
Taskv2: "task_v2",
|
||||
URL: "goto_url",
|
||||
HttpRequest: "http_request",
|
||||
} as const;
|
||||
|
||||
export const debuggableWorkflowBlockTypes: Set<WorkflowBlockType> = new Set([
|
||||
@@ -462,6 +464,17 @@ export type URLBlock = WorkflowBlockBase & {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type HttpRequestBlock = WorkflowBlockBase & {
|
||||
block_type: "http_request";
|
||||
method: string;
|
||||
url: string | null;
|
||||
headers: Record<string, string> | null;
|
||||
body: Record<string, unknown> | null;
|
||||
timeout: number;
|
||||
follow_redirects: boolean;
|
||||
parameters: Array<WorkflowParameter>;
|
||||
};
|
||||
|
||||
export type WorkflowDefinition = {
|
||||
parameters: Array<Parameter>;
|
||||
blocks: Array<WorkflowBlock>;
|
||||
|
||||
@@ -120,7 +120,8 @@ export type BlockYAML =
|
||||
| FileDownloadBlockYAML
|
||||
| PDFParserBlockYAML
|
||||
| Taskv2BlockYAML
|
||||
| URLBlockYAML;
|
||||
| URLBlockYAML
|
||||
| HttpRequestBlockYAML;
|
||||
|
||||
export type BlockYAMLBase = {
|
||||
block_type: WorkflowBlockType;
|
||||
@@ -328,3 +329,14 @@ export type URLBlockYAML = BlockYAMLBase & {
|
||||
block_type: "goto_url";
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type HttpRequestBlockYAML = BlockYAMLBase & {
|
||||
block_type: "http_request";
|
||||
method: string;
|
||||
url: string | null;
|
||||
headers: Record<string, string> | null;
|
||||
body: Record<string, unknown> | null;
|
||||
timeout: number;
|
||||
follow_redirects: boolean;
|
||||
parameter_keys?: Array<string> | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user