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: 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", "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: 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: dataExtractionGoal:
"Tell Skyvern what data you would like to scrape at the end of your run.", "Tell Skyvern what data you would like to scrape at the end of your run.",
dataSchema: "Specify a format for extracted data in JSON.", 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", jsonSchema: "Specify a format for the extracted information from the file",
}, },
url: baseHelpTooltipContent, 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 = { export const placeholders = {
@@ -159,4 +170,11 @@ export const placeholders = {
...basePlaceholderContent, ...basePlaceholderContent,
url: "(required) Navigate to this URL: https://...", 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, EnvelopeClosedIcon,
ExternalLinkIcon, ExternalLinkIcon,
FileTextIcon, FileTextIcon,
GlobeIcon,
ListBulletIcon, ListBulletIcon,
LockOpen1Icon, LockOpen1Icon,
StopwatchIcon, StopwatchIcon,
@@ -79,6 +80,9 @@ function WorkflowBlockIcon({ workflowBlockType, className }: Props) {
case "goto_url": { case "goto_url": {
return <ExternalLinkIcon className={className} />; 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 { Taskv2Node as Taskv2NodeComponent } from "./Taskv2Node/Taskv2Node";
import { URLNode } from "./URLNode/types"; import { URLNode } from "./URLNode/types";
import { URLNode as URLNodeComponent } from "./URLNode/URLNode"; import { URLNode as URLNodeComponent } from "./URLNode/URLNode";
import { HttpRequestNode } from "./HttpRequestNode/types";
import { HttpRequestNode as HttpRequestNodeComponent } from "./HttpRequestNode/HttpRequestNode";
export type UtilityNode = StartNode | NodeAdderNode; export type UtilityNode = StartNode | NodeAdderNode;
@@ -63,7 +65,8 @@ export type WorkflowBlockNode =
| FileDownloadNode | FileDownloadNode
| PDFParserNode | PDFParserNode
| Taskv2Node | Taskv2Node
| URLNode; | URLNode
| HttpRequestNode;
export function isUtilityNode(node: AppNode): node is UtilityNode { export function isUtilityNode(node: AppNode): node is UtilityNode {
return node.type === "nodeAdder" || node.type === "start"; return node.type === "nodeAdder" || node.type === "start";
@@ -97,4 +100,5 @@ export const nodeTypes = {
pdfParser: memo(PDFParserNodeComponent), pdfParser: memo(PDFParserNodeComponent),
taskv2: memo(Taskv2NodeComponent), taskv2: memo(Taskv2NodeComponent),
url: memo(URLNodeComponent), url: memo(URLNodeComponent),
http_request: memo(HttpRequestNodeComponent),
} as const; } as const;

View File

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

View File

@@ -43,6 +43,17 @@ const nodeLibraryItems: Array<{
title: "Navigation Block", title: "Navigation Block",
description: "Navigate on the page", 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", nodeType: "taskv2",
icon: ( icon: (
@@ -74,141 +85,7 @@ const nodeLibraryItems: Array<{
/> />
), ),
title: "Extraction Block", title: "Extraction Block",
description: "Extract data from the page", description: "Extract data from a webpage",
},
{
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",
}, },
{ {
nodeType: "wait", nodeType: "wait",
@@ -219,7 +96,150 @@ const nodeLibraryItems: Array<{
/> />
), ),
title: "Wait Block", 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, WorkflowBlockTypes,
WorkflowParameterTypes, WorkflowParameterTypes,
WorkflowParameterValueType, WorkflowParameterValueType,
debuggableWorkflowBlockTypes,
type AWSSecretParameter, type AWSSecretParameter,
type OutputParameter, type OutputParameter,
type Parameter, type Parameter,
@@ -37,6 +38,7 @@ import {
Taskv2BlockYAML, Taskv2BlockYAML,
URLBlockYAML, URLBlockYAML,
FileUploadBlockYAML, FileUploadBlockYAML,
HttpRequestBlockYAML,
} from "../types/workflowYamlTypes"; } from "../types/workflowYamlTypes";
import { import {
EMAIL_BLOCK_SENDER, EMAIL_BLOCK_SENDER,
@@ -99,7 +101,7 @@ import {
import { taskv2NodeDefaultData } from "./nodes/Taskv2Node/types"; import { taskv2NodeDefaultData } from "./nodes/Taskv2Node/types";
import { urlNodeDefaultData } from "./nodes/URLNode/types"; import { urlNodeDefaultData } from "./nodes/URLNode/types";
import { fileUploadNodeDefaultData } from "./nodes/FileUploadNode/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_"; 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, 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: { default: {
throw new Error("Invalid node type for getWorkflowBlock"); throw new Error("Invalid node type for getWorkflowBlock");
} }
@@ -1986,6 +2032,20 @@ function convertBlocksToBlockYAML(
}; };
return blockYaml; 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 | FileDownloadBlock
| PDFParserBlock | PDFParserBlock
| Taskv2Block | Taskv2Block
| URLBlock; | URLBlock
| HttpRequestBlock;
export const WorkflowBlockTypes = { export const WorkflowBlockTypes = {
Task: "task", Task: "task",
@@ -212,6 +213,7 @@ export const WorkflowBlockTypes = {
PDFParser: "pdf_parser", PDFParser: "pdf_parser",
Taskv2: "task_v2", Taskv2: "task_v2",
URL: "goto_url", URL: "goto_url",
HttpRequest: "http_request",
} as const; } as const;
export const debuggableWorkflowBlockTypes: Set<WorkflowBlockType> = new Set([ export const debuggableWorkflowBlockTypes: Set<WorkflowBlockType> = new Set([
@@ -462,6 +464,17 @@ export type URLBlock = WorkflowBlockBase & {
url: string; 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 = { export type WorkflowDefinition = {
parameters: Array<Parameter>; parameters: Array<Parameter>;
blocks: Array<WorkflowBlock>; blocks: Array<WorkflowBlock>;

View File

@@ -120,7 +120,8 @@ export type BlockYAML =
| FileDownloadBlockYAML | FileDownloadBlockYAML
| PDFParserBlockYAML | PDFParserBlockYAML
| Taskv2BlockYAML | Taskv2BlockYAML
| URLBlockYAML; | URLBlockYAML
| HttpRequestBlockYAML;
export type BlockYAMLBase = { export type BlockYAMLBase = {
block_type: WorkflowBlockType; block_type: WorkflowBlockType;
@@ -328,3 +329,14 @@ export type URLBlockYAML = BlockYAMLBase & {
block_type: "goto_url"; block_type: "goto_url";
url: string; 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;
};