diff --git a/README.md b/README.md index d6a5e29e..f4675d00 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Visit [http://localhost:3000](http://localhost:3000) to get started. - **Firecrawl**: Scrape URL, Search Web - **GitHub**: Create Issue, List Issues, Get Issue, Update Issue - **Linear**: Create Ticket, Find Issues +- **Native**: HTTP Request - **Perplexity**: Search Web, Ask Question, Research Topic - **Resend**: Send Email - **Slack**: Send Slack Message diff --git a/app/workflows/[workflowId]/page.tsx b/app/workflows/[workflowId]/page.tsx index c760640d..a939c371 100644 --- a/app/workflows/[workflowId]/page.tsx +++ b/app/workflows/[workflowId]/page.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { NodeConfigPanel } from "@/components/workflow/node-config-panel"; import { useIsMobile } from "@/hooks/use-mobile"; import { api } from "@/lib/api-client"; +import { fetchIntegrationsAtom } from "@/lib/integrations-store"; import { integrationsAtom, integrationsLoadedAtom, @@ -122,6 +123,7 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { const setTriggerExecute = useSetAtom(triggerExecuteAtom); const setRightPanelWidth = useSetAtom(rightPanelWidthAtom); const setIsPanelAnimating = useSetAtom(isPanelAnimatingAtom); + const fetchIntegrations = useSetAtom(fetchIntegrationsAtom); const [hasSidebarBeenShown, setHasSidebarBeenShown] = useAtom( hasSidebarBeenShownAtom ); @@ -391,6 +393,9 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { const storedPrompt = sessionStorage.getItem("ai-prompt"); const storedWorkflowId = sessionStorage.getItem("generating-workflow-id"); + // Prefetch integrations in parallel with workflow loading + fetchIntegrations(); + // Check if state is already loaded for this workflow if (currentWorkflowId === workflowId && nodes.length > 0) { return; @@ -418,6 +423,7 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { nodes.length, generateWorkflowFromAI, loadExistingWorkflow, + fetchIntegrations, ]); // Auto-fix invalid/missing integrations on workflow load or when integrations change diff --git a/components/settings/integration-form-dialog.tsx b/components/settings/integration-form-dialog.tsx index 14be45dd..5a6f36bc 100644 --- a/components/settings/integration-form-dialog.tsx +++ b/components/settings/integration-form-dialog.tsx @@ -46,9 +46,13 @@ const SYSTEM_INTEGRATION_LABELS: Record = { database: "Database", }; -// Get all integration types (plugins + system) +// Get all integration types (plugins that require integration + system) +// Excludes plugins with requiresIntegration: false (like Native) const getIntegrationTypes = (): IntegrationType[] => [ - ...getSortedIntegrationTypes(), + ...getSortedIntegrationTypes().filter((type) => { + const plugin = getIntegration(type); + return plugin?.requiresIntegration !== false; + }), ...SYSTEM_INTEGRATION_TYPES, ]; diff --git a/components/ui/integration-selector.tsx b/components/ui/integration-selector.tsx index 4d5323ed..9fbe423b 100644 --- a/components/ui/integration-selector.tsx +++ b/components/ui/integration-selector.tsx @@ -2,7 +2,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import { AlertTriangle } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Select, SelectContent, @@ -11,6 +11,11 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; +import { + fetchIntegrationsAtom, + integrationsAtom, + integrationsFetchedAtom, + integrationsLoadingAtom, import { api, type Integration } from "@/lib/api-client"; import { integrationsAtom, @@ -36,13 +41,25 @@ export function IntegrationSelector({ label, disabled, }: IntegrationSelectorProps) { - const [integrations, setIntegrations] = useState([]); - const [loading, setLoading] = useState(true); + const allIntegrations = useAtomValue(integrationsAtom); + const loading = useAtomValue(integrationsLoadingAtom); + const fetched = useAtomValue(integrationsFetchedAtom); + const fetchIntegrations = useSetAtom(fetchIntegrationsAtom); const [showNewDialog, setShowNewDialog] = useState(false); const integrationsVersion = useAtomValue(integrationsVersionAtom); const setGlobalIntegrations = useSetAtom(integrationsAtom); const setIntegrationsVersion = useSetAtom(integrationsVersionAtom); + // Filter integrations by type + const integrations = useMemo( + () => allIntegrations.filter((i) => i.type === integrationType), + [allIntegrations, integrationType] + ); + + // Fetch integrations on mount if not already fetched + useEffect(() => { + if (!fetched && !loading) { + fetchIntegrations(); const loadIntegrations = async () => { try { setLoading(true); @@ -61,9 +78,14 @@ export function IntegrationSelector({ } finally { setLoading(false); } - }; + }, [fetched, loading, fetchIntegrations]); + // Auto-select if only one option and nothing selected yet useEffect(() => { + if (integrations.length === 1 && !value && fetched) { + onChange(integrations[0].id); + } + }, [integrations, value, fetched, onChange]); loadIntegrations(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [integrationType, integrationsVersion]); @@ -79,14 +101,14 @@ export function IntegrationSelector({ }; const handleNewIntegrationCreated = async (integrationId: string) => { - await loadIntegrations(); + await fetchIntegrations(); onChange(integrationId); setShowNewDialog(false); // Increment version to trigger auto-fix for other nodes that need this integration type setIntegrationsVersion((v) => v + 1); }; - if (loading) { + if (loading || !fetched) { return ( + + + + + ); + } + + const getPluginLabel = (type: string): string => { + const plugin = getIntegration(type as Parameters[0]); + return plugin?.label || type; + }; + + return ( + + ); +} + const FIELD_RENDERERS: Record< ActionConfigFieldBase["type"], React.ComponentType @@ -124,6 +339,9 @@ const FIELD_RENDERERS: Record< number: NumberInputField, select: SelectField, "schema-builder": SchemaBuilderField, + "object-builder": ObjectBuilderField, + "integration-select": IntegrationSelectField, + "json-editor": JsonEditorField, }; /** @@ -153,6 +371,7 @@ function renderField( {field.label} onUpdateConfig(field.key, val)} @@ -214,10 +433,6 @@ type ActionConfigRendererProps = { disabled?: boolean; }; -/** - * Renders action config fields declaratively - * Converts ActionConfigField definitions into actual UI components - */ export function ActionConfigRenderer({ fields, config, diff --git a/components/workflow/config/action-config.tsx b/components/workflow/config/action-config.tsx index 3931cf05..f0306770 100644 --- a/components/workflow/config/action-config.tsx +++ b/components/workflow/config/action-config.tsx @@ -80,97 +80,6 @@ function DatabaseQueryFields({ ); } -// HTTP Request fields component -function HttpRequestFields({ - config, - onUpdateConfig, - disabled, -}: { - config: Record; - onUpdateConfig: (key: string, value: string) => void; - disabled: boolean; -}) { - return ( - <> -
- - -
-
- - onUpdateConfig("endpoint", value)} - placeholder="https://api.example.com/endpoint or {{NodeName.url}}" - value={(config?.endpoint as string) || ""} - /> -
-
- -
- onUpdateConfig("httpHeaders", value || "{}")} - options={{ - minimap: { enabled: false }, - lineNumbers: "off", - scrollBeyondLastLine: false, - fontSize: 12, - readOnly: disabled, - wordWrap: "off", - }} - value={(config?.httpHeaders as string) || "{}"} - /> -
-
-
- -
- onUpdateConfig("httpBody", value || "{}")} - options={{ - minimap: { enabled: false }, - lineNumbers: "off", - scrollBeyondLastLine: false, - fontSize: 12, - readOnly: config?.httpMethod === "GET" || disabled, - domReadOnly: config?.httpMethod === "GET" || disabled, - wordWrap: "off", - }} - value={(config?.httpBody as string) || "{}"} - /> -
- {config?.httpMethod === "GET" && ( -

- Body is disabled for GET requests -

- )} -
- - ); -} - // Condition fields component function ConditionFields({ config, @@ -201,7 +110,6 @@ function ConditionFields({ // System actions that don't have plugins const SYSTEM_ACTIONS: Array<{ id: string; label: string }> = [ - { id: "HTTP Request", label: "HTTP Request" }, { id: "Database Query", label: "Database Query" }, { id: "Condition", label: "Condition" }, ]; @@ -365,14 +273,6 @@ export function ActionConfig({ {/* System actions - hardcoded config fields */} - {config?.actionType === "HTTP Request" && ( - - )} - {config?.actionType === "Database Query" && ( ; } - return ; + return ; } export function ActionGrid({ diff --git a/components/workflow/config/object-builder.tsx b/components/workflow/config/object-builder.tsx new file mode 100644 index 00000000..8d89e56c --- /dev/null +++ b/components/workflow/config/object-builder.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { Plus, Trash2 } from "lucide-react"; +import { nanoid } from "nanoid"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { TemplateBadgeInput } from "@/components/ui/template-badge-input"; + +export type ObjectProperty = { + id: string; + key: string; + value: string; +}; + +type ValidateFn = ( + value: string, + property: ObjectProperty +) => string | undefined; + +type PropertyRowProps = { + prop: ObjectProperty; + index: number; + disabled?: boolean; + keyPlaceholder: string; + valuePlaceholder: string; + keyLabel: string; + valueLabel: string; + supportsTemplates: boolean; + keyError?: string; + valueError?: string; + onUpdate: (updates: Partial) => void; + onRemove: () => void; +}; + +function PropertyRow({ + prop, + index, + disabled, + keyPlaceholder, + valuePlaceholder, + keyLabel, + valueLabel, + supportsTemplates, + keyError, + valueError, + onUpdate, + onRemove, +}: PropertyRowProps) { + return ( +
+
+
+ {index === 0 && ( + + )} + onUpdate({ key: e.target.value })} + placeholder={keyPlaceholder} + value={prop.key} + /> +
+
+ {index === 0 && ( + + )} + {supportsTemplates ? ( + onUpdate({ value })} + placeholder={valuePlaceholder} + value={prop.value} + /> + ) : ( + onUpdate({ value: e.target.value })} + placeholder={valuePlaceholder} + value={prop.value} + /> + )} +
+
+ {index === 0 &&
} + +
+
+ {(keyError || valueError) && ( +

{keyError || valueError}

+ )} +
+ ); +} + +type ObjectBuilderProps = { + properties: ObjectProperty[]; + onChange: (properties: ObjectProperty[]) => void; + disabled?: boolean; + keyPlaceholder?: string; + valuePlaceholder?: string; + keyLabel?: string; + valueLabel?: string; + supportsTemplates?: boolean; + validateKey?: ValidateFn; + validateValue?: ValidateFn; +}; + +export function ObjectBuilder({ + properties, + onChange, + disabled, + keyPlaceholder = "key", + valuePlaceholder = "value", + keyLabel = "Key", + valueLabel = "Value", + supportsTemplates = true, + validateKey, + validateValue, +}: ObjectBuilderProps) { + const addProperty = () => { + onChange([...properties, { id: nanoid(), key: "", value: "" }]); + }; + + const updateProperty = (index: number, updates: Partial) => { + const newProperties = [...properties]; + newProperties[index] = { ...newProperties[index], ...updates }; + onChange(newProperties); + }; + + const removeProperty = (index: number) => { + onChange(properties.filter((_, i) => i !== index)); + }; + + return ( +
+ {properties.map((prop, index) => ( + removeProperty(index)} + onUpdate={(updates) => updateProperty(index, updates)} + prop={prop} + supportsTemplates={supportsTemplates} + valueError={validateValue?.(prop.value, prop)} + valueLabel={valueLabel} + valuePlaceholder={valuePlaceholder} + /> + ))} + + +
+ ); +} + +export function propertiesToObject( + properties: ObjectProperty[] +): Record { + const obj: Record = {}; + for (const prop of properties) { + if (prop.key.trim()) { + obj[prop.key] = prop.value; + } + } + return obj; +} + +export function objectToProperties( + obj: Record | undefined | null +): ObjectProperty[] { + if (!obj || typeof obj !== "object") { + return []; + } + return Object.entries(obj).map(([key, value]) => ({ + id: nanoid(), + key, + value: String(value), + })); +} diff --git a/components/workflow/node-config-panel.tsx b/components/workflow/node-config-panel.tsx index f45dbf67..f94fb552 100644 --- a/components/workflow/node-config-panel.tsx +++ b/components/workflow/node-config-panel.tsx @@ -49,7 +49,7 @@ import { showDeleteDialogAtom, updateNodeDataAtom, } from "@/lib/workflow-store"; -import { findActionById } from "@/plugins"; +import { findActionById, requiresIntegration } from "@/plugins"; import { Panel } from "../ai-elements/panel"; import { IntegrationsDialog } from "../settings/integrations-dialog"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; @@ -890,23 +890,26 @@ export const PanelInner = () => { const actionType = selectedNode.data.config ?.actionType as string; + if (!actionType) { + return null; + } + // Database Query is special - has integration but no plugin const SYSTEM_INTEGRATION_MAP: Record = { "Database Query": "database", }; - // Get integration type dynamically - let integrationType: string | undefined; - if (actionType) { - if (SYSTEM_INTEGRATION_MAP[actionType]) { - integrationType = SYSTEM_INTEGRATION_MAP[actionType]; - } else { - // Look up from plugin registry - const action = findActionById(actionType); - integrationType = action?.integration; - } + // Check if this action requires integration + const isSystemAction = actionType in SYSTEM_INTEGRATION_MAP; + if (!(isSystemAction || requiresIntegration(actionType))) { + return null; } + // Get integration type + const integrationType = isSystemAction + ? SYSTEM_INTEGRATION_MAP[actionType] + : findActionById(actionType)?.integration; + return integrationType ? ( { @@ -72,7 +72,6 @@ const getModelDisplayName = (modelId: string): string => { // System action labels (non-plugin actions) const SYSTEM_ACTION_LABELS: Record = { - "HTTP Request": "System", "Database Query": "Database", Condition: "Condition", "Execute Code": "System", @@ -106,25 +105,13 @@ function isBase64ImageOutput(output: unknown): output is { base64: string } { ); } -// Helper to check if an action requires an integration -const requiresIntegration = (actionType: string): boolean => { - // System actions that require integration configuration - const systemActionsRequiringIntegration = ["Database Query"]; - if (systemActionsRequiringIntegration.includes(actionType)) { - return true; - } - - // Plugin actions always require integration - const action = findActionById(actionType); - return action !== undefined; -}; +// System actions that require integration (not in plugin registry) +const SYSTEM_ACTIONS_REQUIRING_INTEGRATION = ["Database Query"]; // Helper to get provider logo for action type const getProviderLogo = (actionType: string) => { // Check for system actions first (non-plugin) switch (actionType) { - case "HTTP Request": - return ; case "Database Query": return ; case "Execute Code": @@ -302,7 +289,9 @@ export const ActionNode = memo(({ data, selected, id }: ActionNodeProps) => { const displayDescription = data.description || getIntegrationFromActionType(actionType); - const needsIntegration = requiresIntegration(actionType); + const needsIntegration = + SYSTEM_ACTIONS_REQUIRING_INTEGRATION.includes(actionType) || + requiresIntegration(actionType); // Don't show missing indicator if we're still checking for auto-select const isPendingIntegrationCheck = pendingIntegrationNodes.has(id); // Check both that integrationId is set AND that it exists in available integrations diff --git a/components/workflow/utils/code-generators.ts b/components/workflow/utils/code-generators.ts index 4027ee65..200c4e01 100644 --- a/components/workflow/utils/code-generators.ts +++ b/components/workflow/utils/code-generators.ts @@ -5,13 +5,11 @@ import { AUTO_GENERATED_TEMPLATES } from "@/lib/codegen-registry"; import conditionTemplate from "@/lib/codegen-templates/condition"; import databaseQueryTemplate from "@/lib/codegen-templates/database-query"; -import httpRequestTemplate from "@/lib/codegen-templates/http-request"; import { findActionById } from "@/plugins"; // System action templates (non-plugin actions) const SYSTEM_ACTION_TEMPLATES: Record = { "Database Query": databaseQueryTemplate, - "HTTP Request": httpRequestTemplate, Condition: conditionTemplate, }; diff --git a/components/workflow/workflow-toolbar.tsx b/components/workflow/workflow-toolbar.tsx index 80f0a7fb..2580709d 100644 --- a/components/workflow/workflow-toolbar.tsx +++ b/components/workflow/workflow-toolbar.tsx @@ -97,6 +97,7 @@ import { findActionById, flattenConfigFields, getIntegrationLabels, + requiresIntegration, } from "@/plugins"; import { Panel } from "../ai-elements/panel"; import { DeployButton } from "../deploy-button"; @@ -309,7 +310,8 @@ function getNodeMissingFields( (field) => field.required && shouldShowField(field, config || {}) && - isFieldEmpty(config?.[field.key]) + isFieldEmpty(config?.[field.key]) && + isFieldEmpty(field.defaultValue) ) .map((field) => ({ fieldKey: field.key, @@ -339,6 +341,7 @@ function getMissingRequiredFields( // Get missing integrations for workflow nodes // Uses the plugin registry to determine which integrations are required // Also handles built-in actions that aren't in the plugin registry +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: validation logic with multiple checks function getMissingIntegrations( nodes: WorkflowNode[], userIntegrations: Array<{ id: string; type: IntegrationType }> @@ -359,9 +362,14 @@ function getMissingIntegrations( continue; } - // Look up the integration type from the plugin registry first + // Check if this action requires integration (respects requiresIntegration flag) + const isBuiltinAction = actionType in BUILTIN_ACTION_INTEGRATIONS; + if (!(isBuiltinAction || requiresIntegration(actionType))) { + continue; + } + + // Get the integration type const action = findActionById(actionType); - // Fall back to built-in action integrations for actions not in the registry const requiredIntegrationType = action?.integration || BUILTIN_ACTION_INTEGRATIONS[actionType]; diff --git a/lib/codegen-templates/http-request.ts b/lib/codegen-templates/http-request.ts deleted file mode 100644 index d297e640..00000000 --- a/lib/codegen-templates/http-request.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Code template for HTTP Request action step - * This is a string template used for code generation - keep as string export - */ -export default `export async function httpRequestStep(input: { - endpoint: string; - httpMethod: string; - httpHeaders?: string; - httpBody?: string; -}) { - "use step"; - - let headers = {}; - if (input.httpHeaders) { - try { - headers = JSON.parse(input.httpHeaders); - } catch { - // If parsing fails, use empty headers - } - } - - let body: string | undefined; - if (input.httpMethod !== "GET" && input.httpBody) { - try { - const parsedBody = JSON.parse(input.httpBody); - if (Object.keys(parsedBody).length > 0) { - body = JSON.stringify(parsedBody); - } - } catch { - if (input.httpBody.trim() && input.httpBody.trim() !== "{}") { - body = input.httpBody; - } - } - } - - const response = await fetch(input.endpoint, { - method: input.httpMethod, - headers, - body, - }); - - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - return await response.json(); - } - return await response.text(); -}`; diff --git a/lib/integrations-store.ts b/lib/integrations-store.ts index 44ad976b..ca4cd42a 100644 --- a/lib/integrations-store.ts +++ b/lib/integrations-store.ts @@ -1,15 +1,45 @@ import { atom } from "jotai"; -import type { Integration } from "@/lib/api-client"; +import { api, type Integration } from "@/lib/api-client"; // Store for all user integrations export const integrationsAtom = atom([]); +// Loading state for integrations +export const integrationsLoadingAtom = atom(false); + +// Track if integrations have been fetched at least once +export const integrationsFetchedAtom = atom(false); // Track if integrations have been loaded (to avoid showing warnings before fetch) export const integrationsLoadedAtom = atom(false); // Selected integration for forms/dialogs export const selectedIntegrationAtom = atom(null); +// Fetch integrations action - returns the fetched integrations +export const fetchIntegrationsAtom = atom(null, async (get, set) => { + // Skip if already loading + if (get(integrationsLoadingAtom)) { + return get(integrationsAtom); + } + + set(integrationsLoadingAtom, true); + try { + const integrations = await api.integration.getAll(); + set(integrationsAtom, integrations); + set(integrationsFetchedAtom, true); + return integrations; + } catch (error) { + console.error("Failed to fetch integrations:", error); + return get(integrationsAtom); + } finally { + set(integrationsLoadingAtom, false); + } +}); + +// Get integrations by type (derived atom) +export const integrationsByTypeAtom = atom((get) => { + const integrations = get(integrationsAtom); + return (type: string) => integrations.filter((i) => i.type === type); // Version counter that increments when integrations are added/deleted/modified // Components can use this to know when to re-fetch integrations export const integrationsVersionAtom = atom(0); diff --git a/lib/steps/http-request.ts b/lib/steps/http-request.ts deleted file mode 100644 index 00cd7c43..00000000 --- a/lib/steps/http-request.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Executable step function for HTTP Request action - */ -import "server-only"; - -import { getErrorMessage } from "../utils"; -import { type StepInput, withStepLogging } from "./step-handler"; - -type HttpRequestResult = - | { success: true; data: unknown; status: number } - | { success: false; error: string; status?: number }; - -export type HttpRequestInput = StepInput & { - endpoint: string; - httpMethod: string; - httpHeaders?: string; - httpBody?: string; -}; - -function parseHeaders(httpHeaders?: string): Record { - if (!httpHeaders) { - return {}; - } - try { - return JSON.parse(httpHeaders); - } catch { - return {}; - } -} - -function parseBody(httpMethod: string, httpBody?: string): string | undefined { - if (httpMethod === "GET" || !httpBody) { - return; - } - try { - const parsedBody = JSON.parse(httpBody); - return Object.keys(parsedBody).length > 0 - ? JSON.stringify(parsedBody) - : undefined; - } catch { - const trimmed = httpBody.trim(); - return trimmed && trimmed !== "{}" ? httpBody : undefined; - } -} - -function parseResponse(response: Response): Promise { - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - return response.json(); - } - return response.text(); -} - -/** - * HTTP request logic - */ -async function httpRequest( - input: HttpRequestInput -): Promise { - if (!input.endpoint) { - return { - success: false, - error: "HTTP request failed: URL is required", - }; - } - - try { - const response = await fetch(input.endpoint, { - method: input.httpMethod, - headers: parseHeaders(input.httpHeaders), - body: parseBody(input.httpMethod, input.httpBody), - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => "Unknown error"); - return { - success: false, - error: `HTTP request failed with status ${response.status}: ${errorText}`, - status: response.status, - }; - } - - const data = await parseResponse(response); - return { success: true, data, status: response.status }; - } catch (error) { - return { - success: false, - error: `HTTP request failed: ${getErrorMessage(error)}`, - }; - } -} - -/** - * HTTP Request Step - * Makes an HTTP request to an endpoint - */ -// biome-ignore lint/suspicious/useAwait: workflow "use step" requires async -export async function httpRequestStep( - input: HttpRequestInput -): Promise { - "use step"; - return withStepLogging(input, () => httpRequest(input)); -} -httpRequestStep.maxRetries = 0; diff --git a/lib/steps/index.ts b/lib/steps/index.ts index 3ffb4d2d..bfcc3711 100644 --- a/lib/steps/index.ts +++ b/lib/steps/index.ts @@ -13,17 +13,12 @@ import type { sendEmailStep } from "../../plugins/resend/steps/send-email"; import type { sendSlackMessageStep } from "../../plugins/slack/steps/send-slack-message"; import type { conditionStep } from "./condition"; import type { databaseQueryStep } from "./database-query"; -import type { httpRequestStep } from "./http-request"; // Step function type export type StepFunction = (input: Record) => Promise; // Registry of all available steps export const stepRegistry: Record = { - "HTTP Request": async (input) => - (await import("./http-request")).httpRequestStep( - input as Parameters[0] - ), "Database Query": async (input) => (await import("./database-query")).databaseQueryStep( input as Parameters[0] diff --git a/lib/workflow-codegen-sdk.ts b/lib/workflow-codegen-sdk.ts index 598bca7c..ac3cea14 100644 --- a/lib/workflow-codegen-sdk.ts +++ b/lib/workflow-codegen-sdk.ts @@ -4,12 +4,10 @@ import { findActionById } from "@/plugins"; // System action codegen templates (not in plugin registry) import conditionTemplate from "./codegen-templates/condition"; import databaseQueryTemplate from "./codegen-templates/database-query"; -import httpRequestTemplate from "./codegen-templates/http-request"; // System actions that don't have plugins const SYSTEM_CODEGEN_TEMPLATES: Record = { "Database Query": databaseQueryTemplate, - "HTTP Request": httpRequestTemplate, Condition: conditionTemplate, }; @@ -320,15 +318,47 @@ export function generateWorkflowSDKCode( } function buildHttpParams(config: Record): string[] { - const params = [ - `url: "${config.endpoint || "https://api.example.com/endpoint"}"`, - `method: "${config.httpMethod || "POST"}"`, - `headers: ${config.httpHeaders || "{}"}`, - ]; - if (config.httpBody) { - params.push(`body: ${config.httpBody}`); + const endpoint = (config.endpoint as string) || ""; + const method = (config.httpMethod as string) || "GET"; + + // Helper to format object properties + function formatProperties(props: unknown): string { + if (!props) { + return "{}"; + } + + let entries: [string, string][] = []; + + if (Array.isArray(props)) { + entries = props + .filter((p) => p.key?.trim()) + .map((p) => [p.key, String(p.value || "")]); + } else if (typeof props === "object") { + entries = Object.entries(props as Record).map( + ([k, v]) => [k, String(v)] + ); + } + + if (entries.length === 0) { + return "{}"; + } + + const propStrings = entries.map(([k, v]) => { + const key = JSON.stringify(k); + const converted = convertTemplateToJS(v); + const escaped = escapeForTemplateLiteral(converted); + return `${key}: \`${escaped}\``; + }); + + return `{ ${propStrings.join(", ")} }`; } - return params; + + return [ + `endpoint: \`${convertTemplateToJS(endpoint)}\``, + `httpMethod: "${method}"`, + `httpHeaders: ${formatProperties(config.httpHeaders)}`, + `httpBody: ${formatProperties(config.httpBody)}`, + ]; } function buildConditionParams(config: Record): string[] { @@ -405,6 +435,7 @@ export function generateWorkflowSDKCode( "Generate Image": () => buildAIImageParams(config), "Database Query": () => buildDatabaseParams(config), "HTTP Request": () => buildHttpParams(config), + "native/http-request": () => buildHttpParams(config), Condition: () => buildConditionParams(config), Scrape: () => buildFirecrawlParams(actionType, config), Search: () => buildFirecrawlParams(actionType, config), diff --git a/lib/workflow-codegen-shared.ts b/lib/workflow-codegen-shared.ts index 9ccbcc64..d3562991 100644 --- a/lib/workflow-codegen-shared.ts +++ b/lib/workflow-codegen-shared.ts @@ -294,10 +294,6 @@ const SYSTEM_STEP_INFO: Record< functionName: "databaseQueryStep", importPath: "./steps/database-query-step", }, - "HTTP Request": { - functionName: "httpRequestStep", - importPath: "./steps/http-request-step", - }, Condition: { functionName: "conditionStep", importPath: "./steps/condition-step", diff --git a/lib/workflow-codegen.ts b/lib/workflow-codegen.ts index fabac703..d3ae4512 100644 --- a/lib/workflow-codegen.ts +++ b/lib/workflow-codegen.ts @@ -382,15 +382,62 @@ export function generateWorkflowCode( ); const config = node.data.config || {}; - const endpoint = - (config.endpoint as string) || "https://api.example.com/endpoint"; - const method = (config.httpMethod as string) || "POST"; + const endpoint = (config.endpoint as string) || ""; + const method = (config.httpMethod as string) || "GET"; + const headers = config.httpHeaders; + const body = config.httpBody; + + // Helper to format object properties (from ObjectProperty[], Record, or JSON string) + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Multiple type checks needed for different input formats + function formatProperties(props: unknown): string { + if (!props) { + return "{}"; + } + + // Handle JSON string input (from ObjectBuilder serialization) + let parsedProps = props; + if (typeof props === "string") { + try { + parsedProps = JSON.parse(props); + } catch { + // If parsing fails, return empty object + return "{}"; + } + } + + let entries: [string, string][] = []; + + if (Array.isArray(parsedProps)) { + // Handle ObjectProperty[] + entries = parsedProps + .filter((p) => p.key?.trim()) + .map((p) => [p.key, String(p.value || "")]); + } else if (typeof parsedProps === "object" && parsedProps !== null) { + // Handle plain object + entries = Object.entries(parsedProps as Record).map( + ([k, v]) => [k, String(v)] + ); + } + + if (entries.length === 0) { + return "{}"; + } + + const propStrings = entries.map(([k, v]) => { + const key = JSON.stringify(k); + const val = formatTemplateValue(v); + return `${key}: ${val}`; + }); + + return `{ ${propStrings.join(", ")} }`; + } return [ `${indent}const ${varName} = await ${stepInfo.functionName}({`, - `${indent} url: '${endpoint}',`, - `${indent} method: '${method}',`, - `${indent} body: {},`, + `${indent} endpoint: ${formatTemplateValue(endpoint)},`, + `${indent} httpMethod: '${method}',`, + `${indent} httpHeaders: ${formatProperties(headers)},`, + `${indent} httpBody: ${formatProperties(body)},`, `${indent}});`, ]; } @@ -721,7 +768,10 @@ export function generateWorkflowCode( lines.push( ...wrapActionCall(generateDatabaseActionCode(node, indent, varName)) ); - } else if (actionType === "HTTP Request") { + } else if ( + actionType === "HTTP Request" || + actionType === "native/http-request" + ) { lines.push( ...wrapActionCall(generateHTTPActionCode(node, indent, varName)) ); diff --git a/lib/workflow-executor.workflow.ts b/lib/workflow-executor.workflow.ts index afcf0157..a3aa1187 100644 --- a/lib/workflow-executor.workflow.ts +++ b/lib/workflow-executor.workflow.ts @@ -24,11 +24,6 @@ const SYSTEM_ACTIONS: Record = { importer: () => import("./steps/database-query") as Promise, stepFunction: "databaseQueryStep", }, - "HTTP Request": { - // biome-ignore lint/suspicious/noExplicitAny: Dynamic module import - importer: () => import("./steps/http-request") as Promise, - stepFunction: "httpRequestStep", - }, Condition: { // biome-ignore lint/suspicious/noExplicitAny: Dynamic module import importer: () => import("./steps/condition") as Promise, @@ -232,7 +227,7 @@ async function executeActionStep(input: { }); } - // Check system actions first (Database Query, HTTP Request) + // Check system actions first (Database Query, Condition) const systemAction = SYSTEM_ACTIONS[actionType]; if (systemAction) { const module = await systemAction.importer(); diff --git a/plugins/firecrawl/index.ts b/plugins/firecrawl/index.ts index 5d504d8f..47e3662e 100644 --- a/plugins/firecrawl/index.ts +++ b/plugins/firecrawl/index.ts @@ -32,6 +32,15 @@ const firecrawlPlugin: IntegrationPlugin = { }, }, + // HTTP configuration for custom API requests + // Allows users to make direct API calls to Firecrawl via HTTP Request step + httpConfig: { + baseUrl: "https://api.firecrawl.dev/v1", + authHeader: "Authorization", + authPrefix: "Bearer ", + authCredentialKey: "FIRECRAWL_API_KEY", + }, + actions: [ { slug: "scrape", diff --git a/plugins/index.ts b/plugins/index.ts index c2b41249..801af21b 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -12,6 +12,8 @@ * To remove an integration: * 1. Delete the plugin directory * 2. Run: pnpm discover-plugins (or it runs automatically on build) + * + * Discovered plugins: ai-gateway, blob, fal, firecrawl, github, linear, native, perplexity, resend, slack, stripe, superagent, v0 */ import "./ai-gateway"; @@ -20,6 +22,7 @@ import "./fal"; import "./firecrawl"; import "./github"; import "./linear"; +import "./native"; import "./perplexity"; import "./resend"; import "./slack"; @@ -34,6 +37,7 @@ export type { ActionWithFullId, IntegrationPlugin, PluginAction, + PluginHttpConfig, } from "./registry"; // Export the registry utilities @@ -49,12 +53,15 @@ export { getAllIntegrations, getCredentialMapping, getDependenciesForActions, + getHttpEnabledPlugins, getIntegration, getIntegrationLabels, getIntegrationTypes, getPluginEnvVars, + getPluginHttpConfig, getSortedIntegrationTypes, isFieldGroup, parseActionId, registerIntegration, + requiresIntegration, } from "./registry"; diff --git a/plugins/legacy-mappings.ts b/plugins/legacy-mappings.ts index 47c0ee44..5738a8f2 100644 --- a/plugins/legacy-mappings.ts +++ b/plugins/legacy-mappings.ts @@ -9,6 +9,9 @@ * TODO: Remove this file once all workflows have been migrated to the new format. */ export const LEGACY_ACTION_MAPPINGS: Record = { + // Native + "HTTP Request": "native/http-request", + // Firecrawl Scrape: "firecrawl/scrape", Search: "firecrawl/search", diff --git a/plugins/linear/index.ts b/plugins/linear/index.ts index af9689de..2d495a68 100644 --- a/plugins/linear/index.ts +++ b/plugins/linear/index.ts @@ -42,6 +42,14 @@ const linearPlugin: IntegrationPlugin = { }, }, + // HTTP configuration for custom API requests + // Allows users to make direct GraphQL calls to Linear via HTTP Request step + httpConfig: { + baseUrl: "https://api.linear.app", + authHeader: "Authorization", + authCredentialKey: "LINEAR_API_KEY", + }, + actions: [ { slug: "create-ticket", diff --git a/plugins/native/icon.tsx b/plugins/native/icon.tsx new file mode 100644 index 00000000..535d7288 --- /dev/null +++ b/plugins/native/icon.tsx @@ -0,0 +1,25 @@ +/** + * Native Plugin Icon + * Globe icon representing network/HTTP requests + */ + +export function NativeIcon({ className }: { className?: string }) { + return ( + + Native + + + + + ); +} diff --git a/plugins/native/index.ts b/plugins/native/index.ts new file mode 100644 index 00000000..f681ba02 --- /dev/null +++ b/plugins/native/index.ts @@ -0,0 +1,93 @@ +import type { IntegrationPlugin } from "../registry"; +import { registerIntegration } from "../registry"; +import { NativeIcon } from "./icon"; + +const nativePlugin: IntegrationPlugin = { + type: "native", + label: "Native", + description: "Built-in actions that don't require external integrations", + requiresIntegration: false, + icon: NativeIcon, + formFields: [], + actions: [ + { + slug: "http-request", + label: "HTTP Request", + description: "Make an HTTP request to any API endpoint", + category: "Native", + stepFunction: "httpRequestStep", + stepImportPath: "http-request", + outputFields: [ + { field: "data", description: "Response data" }, + { field: "status", description: "HTTP status code" }, + ], + configFields: [ + { + key: "integrationId", + label: "Use Integration (Optional)", + type: "integration-select", + placeholder: "None - Manual Authentication", + }, + { + key: "httpMethod", + label: "HTTP Method", + type: "select", + options: [ + { value: "GET", label: "GET" }, + { value: "POST", label: "POST" }, + { value: "PUT", label: "PUT" }, + { value: "PATCH", label: "PATCH" }, + { value: "DELETE", label: "DELETE" }, + ], + defaultValue: "GET", + required: true, + }, + { + key: "endpoint", + label: "Endpoint", + type: "template-input", + placeholder: "https://api.example.com/endpoint or /path (with integration)", + example: "https://api.example.com/data", + required: true, + }, + { + key: "httpHeaders", + label: "Request Headers", + type: "object-builder", + placeholder: "Header name", + validateKey: (key, value) => { + if (!key && value) { + return "Header name is required"; + } + if (key && !/^[A-Za-z0-9_-]+$/.test(key)) { + return "Header name can only contain letters, numbers, hyphens, and underscores"; + } + if (key && key.toLowerCase() === "content-type") { + return "Content-Type is automatically set to application/json"; + } + return undefined; + }, + validateValue: (value) => { + if (value.includes("{{")) { + return undefined; + } + if (!/^[A-Za-z0-9 _:;.,\\/"'?!(){}[\]@<>=\-+*#$&`|~^%]*$/.test(value)) { + return "Header value contains invalid characters"; + } + return undefined; + }, + }, + { + key: "httpBody", + label: "Request Body", + type: "json-editor", + defaultValue: "{}", + }, + ], + }, + ], +}; + +registerIntegration(nativePlugin); + +export default nativePlugin; diff --git a/plugins/native/steps/http-request.ts b/plugins/native/steps/http-request.ts new file mode 100644 index 00000000..93786358 --- /dev/null +++ b/plugins/native/steps/http-request.ts @@ -0,0 +1,204 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { getIntegrationById } from "@/lib/db/integrations"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import { getPluginHttpConfig } from "@/plugins/registry"; + +type HttpRequestResult = + | { success: true; data: unknown; status: number } + | { success: false; error: string; status?: number }; + +type ObjectProperty = { + id: string; + key: string; + value: string; +}; + +export type HttpRequestInput = StepInput & { + integrationId?: string; + endpoint: string; + httpMethod: string; + httpHeaders?: string | Record | ObjectProperty[]; + httpBody?: string | Record | ObjectProperty[]; +}; + +function propertiesToObject( + properties: ObjectProperty[] +): Record { + const obj: Record = {}; + for (const prop of properties) { + if (prop.key?.trim()) { + obj[prop.key] = prop.value; + } + } + return obj; +} + +function parseHeaders( + httpHeaders?: string | Record | ObjectProperty[] +): Record { + if (!httpHeaders) { + return {}; + } + + if (Array.isArray(httpHeaders)) { + return propertiesToObject(httpHeaders); + } + + if (typeof httpHeaders === "object") { + return httpHeaders; + } + + try { + const parsed = JSON.parse(httpHeaders); + if (Array.isArray(parsed)) { + return propertiesToObject(parsed); + } + return parsed; + } catch { + return {}; + } +} + +function parseBody( + httpMethod: string, + httpBody?: string | Record | ObjectProperty[] +): string | undefined { + + if (httpMethod === "GET" || !httpBody) { + return undefined; + } + + if (Array.isArray(httpBody)) { + const obj = propertiesToObject(httpBody); + return Object.keys(obj).length > 0 ? JSON.stringify(obj) : undefined; + } + + if (typeof httpBody === "object") { + return Object.keys(httpBody).length > 0 + ? JSON.stringify(httpBody) + : undefined; + } + + try { + const parsed = JSON.parse(httpBody); + + if (Array.isArray(parsed)) { + const obj = propertiesToObject(parsed); + return Object.keys(obj).length > 0 ? JSON.stringify(obj) : undefined; + } + return Object.keys(parsed).length > 0 ? JSON.stringify(parsed) : undefined; + } catch { + + const trimmed = httpBody.trim(); + return trimmed && trimmed !== "{}" ? httpBody : undefined; + } +} + +async function parseResponse(response: Response): Promise { + const contentType = response.headers.get("content-type"); + if (contentType?.includes("application/json")) { + return response.json(); + } + return response.text(); +} + +function buildUrl(endpoint: string, baseUrl?: string): string { + if (!baseUrl || endpoint.startsWith("http://") || endpoint.startsWith("https://")) { + return endpoint; + } + + const normalizedBase = baseUrl.replace(/\/$/, ""); + const normalizedPath = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; + + return `${normalizedBase}${normalizedPath}`; +} + +async function httpRequest( + input: HttpRequestInput +): Promise { + if (!input.endpoint) { + return { + success: false, + error: "HTTP request failed: URL is required", + }; + } + + const headers = parseHeaders(input.httpHeaders); + const body = parseBody(input.httpMethod, input.httpBody); + let finalUrl = input.endpoint; + + if (input.integrationId) { + try { + const integration = await getIntegrationById(input.integrationId); + if (!integration) { + return { + success: false, + error: `Integration not found: ${input.integrationId}`, + }; + } + + const httpConfig = getPluginHttpConfig(integration.type); + if (!httpConfig) { + return { + success: false, + error: `Integration "${integration.type}" does not support HTTP requests`, + }; + } + + finalUrl = buildUrl(input.endpoint, httpConfig.baseUrl); + + const credentials = await fetchCredentials(input.integrationId); + const authValue = credentials[httpConfig.authCredentialKey]; + + if (authValue) { + const authHeader = httpConfig.authHeader || "Authorization"; + const authPrefix = httpConfig.authPrefix ?? "Bearer "; + headers[authHeader] = `${authPrefix}${authValue}`; + } + } catch (error) { + return { + success: false, + error: `Failed to fetch integration credentials: ${getErrorMessage(error)}`, + }; + } + } + + if (body && !headers["Content-Type"] && !headers["content-type"]) { + headers["Content-Type"] = "application/json"; + } + + try { + const response = await fetch(finalUrl, { + method: input.httpMethod, + headers, + body, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + return { + success: false, + error: `HTTP request failed with status ${response.status}: ${errorText}`, + status: response.status, + }; + } + + const data = await parseResponse(response); + return { success: true, data, status: response.status }; + } catch (error) { + return { + success: false, + error: `HTTP request failed: ${getErrorMessage(error)}`, + }; + } +} + +export async function httpRequestStep( + input: HttpRequestInput +): Promise { + "use step"; + return withStepLogging(input, () => httpRequest(input)); +} diff --git a/plugins/registry.ts b/plugins/registry.ts index 8a31d1c7..a524f31f 100644 --- a/plugins/registry.ts +++ b/plugins/registry.ts @@ -28,7 +28,10 @@ export type ActionConfigFieldBase = { | "text" // Regular text input | "number" // Number input | "select" // Dropdown select - | "schema-builder"; // Schema builder for structured output + | "schema-builder" // Schema builder for structured output + | "object-builder" // Object builder for key-value pairs (headers, body) + | "integration-select" // Dynamic dropdown of user's integrations with httpConfig + | "json-editor"; // JSON code editor with validation // Placeholder text placeholder?: string; @@ -56,6 +59,11 @@ export type ActionConfigFieldBase = { field: string; equals: string; }; + + // Validation for object-builder fields + // Returns error message string or undefined if valid + validateKey?: (key: string, value: string) => string | undefined; + validateValue?: (value: string, key: string) => string | undefined; }; /** @@ -139,6 +147,26 @@ export type PluginAction = { codegenTemplate?: string; }; +/** + * HTTP Configuration for plugins + * Allows the HTTP Request step to use plugin credentials for custom API calls + */ +export type PluginHttpConfig = { + // Base URL for the API (e.g., "https://api.resend.com") + baseUrl: string; + + // Header name for authentication (default: "Authorization") + authHeader?: string; + + // Prefix for the auth value (default: "Bearer ") + // Use empty string for APIs that expect raw API keys + authPrefix?: string; + + // Which credential key to use for auth (e.g., "RESEND_API_KEY") + // This should match an envVar from formFields + authCredentialKey: string; +}; + /** * Integration Plugin Definition * All information needed to register a new integration in one place @@ -149,6 +177,10 @@ export type IntegrationPlugin = { label: string; description: string; + // Whether this plugin requires an integration to be configured (default: true) + // Set to false for plugins like Native/HTTP Request that don't need credentials + requiresIntegration?: boolean; + // Icon component (should be exported from plugins/[name]/icon.tsx) icon: React.ComponentType<{ className?: string }>; @@ -179,6 +211,10 @@ export type IntegrationPlugin = { // to reduce supply chain attack surface. Only use for codegen if absolutely necessary. dependencies?: Record; + // HTTP configuration for custom API requests via HTTP Request step + // When defined, this plugin's integrations will appear in the HTTP Request integration dropdown + httpConfig?: PluginHttpConfig; + // Actions provided by this integration actions: PluginAction[]; }; @@ -531,3 +567,36 @@ export function generateAIActionPrompts(): string { return lines.join("\n"); } + +/** + * Check if an action requires an integration to be configured + * Returns false for plugins with requiresIntegration: false (like Native) + */ +export function requiresIntegration(actionType: string): boolean { + const action = findActionById(actionType); + if (!action) { + return false; + } + + const plugin = integrationRegistry.get(action.integration); + return plugin?.requiresIntegration !== false; +} + +/** + * Get all plugins that have HTTP configuration + * These plugins support custom API requests via the HTTP Request step + */ +export function getHttpEnabledPlugins(): IntegrationPlugin[] { + return Array.from(integrationRegistry.values()).filter( + (plugin) => plugin.httpConfig !== undefined + ); +} + +/** + * Get HTTP config for a specific plugin type + */ +export function getPluginHttpConfig( + integrationType: IntegrationType +): PluginHttpConfig | undefined { + return integrationRegistry.get(integrationType)?.httpConfig; +} diff --git a/plugins/resend/index.ts b/plugins/resend/index.ts index 0555f94c..8e99c547 100644 --- a/plugins/resend/index.ts +++ b/plugins/resend/index.ts @@ -41,6 +41,15 @@ const resendPlugin: IntegrationPlugin = { }, }, + // HTTP configuration for custom API requests + // Allows users to make direct API calls to Resend via HTTP Request step + httpConfig: { + baseUrl: "https://api.resend.com", + authHeader: "Authorization", + authPrefix: "Bearer ", + authCredentialKey: "RESEND_API_KEY", + }, + actions: [ { slug: "send-email", diff --git a/plugins/slack/index.ts b/plugins/slack/index.ts index 143ed6ba..4f02795f 100644 --- a/plugins/slack/index.ts +++ b/plugins/slack/index.ts @@ -32,6 +32,15 @@ const slackPlugin: IntegrationPlugin = { }, }, + // HTTP configuration for custom API requests + // Allows users to make direct API calls to Slack via HTTP Request step + httpConfig: { + baseUrl: "https://slack.com/api", + authHeader: "Authorization", + authPrefix: "Bearer ", + authCredentialKey: "SLACK_API_KEY", + }, + actions: [ { slug: "send-message", diff --git a/scripts/discover-plugins.ts b/scripts/discover-plugins.ts index 0b1e2fef..0e357373 100644 --- a/scripts/discover-plugins.ts +++ b/scripts/discover-plugins.ts @@ -125,6 +125,7 @@ export type { ActionWithFullId, IntegrationPlugin, PluginAction, + PluginHttpConfig, } from "./registry"; // Export the registry utilities @@ -140,14 +141,17 @@ export { getAllIntegrations, getCredentialMapping, getDependenciesForActions, + getHttpEnabledPlugins, getIntegration, getIntegrationLabels, getIntegrationTypes, getPluginEnvVars, + getPluginHttpConfig, getSortedIntegrationTypes, isFieldGroup, parseActionId, registerIntegration, + requiresIntegration, } from "./registry"; `;