added UI for HTTP block (#2900)

This commit is contained in:
Prakash Maheshwaran
2025-07-08 12:33:05 -04:00
committed by GitHub
parent 427ad4d9ac
commit c9431fad6f
14 changed files with 1553 additions and 141 deletions

View File

@@ -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}',
},
};

View File

@@ -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>
);
}

View File

@@ -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 };

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,6 @@
export { HttpRequestNode } from "./HttpRequestNode";
export type {
HttpRequestNode as HttpRequestNodeType,
HttpRequestNodeData,
} from "./types";
export { httpRequestNodeDefaultData, isHttpRequestNode } from "./types";

View File

@@ -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";
}

View File

@@ -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} />;
}
}
}

View File

@@ -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;

View File

@@ -52,4 +52,5 @@ export const workflowBlockTitle: {
pdf_parser: "PDF Parser",
task_v2: "Task v2",
goto_url: "Go to URL",
http_request: "HTTP Request",
};

View File

@@ -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",
},
];

View File

@@ -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;
}
}
});
}

View File

@@ -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>;

View File

@@ -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;
};