diff --git a/connect-react-demo/.env.example b/connect-react-demo/.env.example index 0a0dfb8..33cb446 100644 --- a/connect-react-demo/.env.example +++ b/connect-react-demo/.env.example @@ -4,3 +4,4 @@ PIPEDREAM_PROJECT_ID= # Starts with 'proj_', available in your PIPEDREAM_PROJECT_ENVIRONMENT=development PIPEDREAM_ALLOWED_ORIGINS='["https://example.com", "http://localhost:3000"]' #NEXT_PUBLIC_EXTERNAL_USER_ID= # Set a static external-user-id here for easier debugging +#NEXT_PUBLIC_ENABLE_PROXY_INPUT=true # Allow editing externalUserId and accountId fields in proxy mode diff --git a/connect-react-demo/app/actions/backendClient.ts b/connect-react-demo/app/actions/backendClient.ts index 794169a..12a2505 100644 --- a/connect-react-demo/app/actions/backendClient.ts +++ b/connect-react-demo/app/actions/backendClient.ts @@ -7,6 +7,14 @@ export type FetchTokenOpts = { externalUserId: string } +export type ProxyRequestOpts = { + externalUserId: string + accountId: string + url: string + method: string + data?: any +} + const allowedOrigins = ([ process.env.VERCEL_URL, process.env.VERCEL_BRANCH_URL, @@ -32,3 +40,55 @@ const _fetchToken = async (opts: FetchTokenOpts) => { // export const fetchToken = unstable_cache(_fetchToken, [], { revalidate: 3600 }) export const fetchToken = _fetchToken + +const _proxyRequest = async (opts: ProxyRequestOpts) => { + const serverClient = backendClient() + + try { + const proxyOptions = { + searchParams: { + external_user_id: opts.externalUserId, + account_id: opts.accountId + } + } + + const targetRequest = { + url: opts.url, + options: { + method: opts.method as "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + ...(opts.data && { body: JSON.stringify(opts.data) }), + ...(opts.data && { headers: { "Content-Type": "application/json" } }) + } + } + + const resp = await serverClient.makeProxyRequest(proxyOptions, targetRequest); + + // Log the response structure for debugging + console.log('Proxy response structure:', { + resp, + type: typeof resp, + keys: Object.keys(resp || {}), + hasHeaders: !!(resp as any)?.headers, + hasStatus: !!(resp as any)?.status + }); + + // Return both the response data and any available metadata (like headers) + // Note: The Pipedream SDK might return headers differently + return { + data: resp, + headers: (resp as any)?.headers || (resp as any)?.response?.headers || {}, + status: (resp as any)?.status || (resp as any)?.response?.status || 200, + rawResponse: resp // Include raw response for debugging + } + } catch (error: any) { + // Re-throw with structured error info + throw { + message: error.message || 'Proxy request failed', + status: error.response?.status, + data: error.response?.data, + headers: error.response?.headers + } + } +} + +export const proxyRequest = _proxyRequest diff --git a/connect-react-demo/app/components/ComponentTypeSelector.tsx b/connect-react-demo/app/components/ComponentTypeSelector.tsx index 4930592..2af4929 100644 --- a/connect-react-demo/app/components/ComponentTypeSelector.tsx +++ b/connect-react-demo/app/components/ComponentTypeSelector.tsx @@ -1,10 +1,10 @@ import { cn } from "@/lib/utils" -import { IoCubeSharp, IoFlashOutline } from "react-icons/io5" +import { IoCubeSharp, IoFlashOutline, IoGlobe } from "react-icons/io5" import { TOGGLE_STYLES } from "@/lib/constants/ui" interface ComponentTypeSelectorProps { - selectedType: "action" | "trigger" - onTypeChange: (type: "action" | "trigger") => void + selectedType: "action" | "trigger" | "proxy" + onTypeChange: (type: "action" | "trigger" | "proxy") => void } const COMPONENT_TYPES = [ @@ -20,6 +20,12 @@ const COMPONENT_TYPES = [ icon: IoFlashOutline, description: "React to events and webhooks" }, + { + value: "proxy", + label: "Proxy", + icon: IoGlobe, + description: "Make direct API requests through authenticated accounts" + }, ] as const export function ComponentTypeSelector({ selectedType, onTypeChange }: ComponentTypeSelectorProps) { @@ -42,7 +48,7 @@ export function ComponentTypeSelector({ selectedType, onTypeChange }: ComponentT {type.label} - {index === 0 &&
} + {index < COMPONENT_TYPES.length - 1 &&
}
))}
diff --git a/connect-react-demo/app/components/ConfigPanel.tsx b/connect-react-demo/app/components/ConfigPanel.tsx index fc12b43..3d0eb79 100644 --- a/connect-react-demo/app/components/ConfigPanel.tsx +++ b/connect-react-demo/app/components/ConfigPanel.tsx @@ -182,7 +182,14 @@ export const ConfigPanel = () => { propNames, webhookUrlValidationAttempted, setWebhookUrlValidationAttempted, + editableExternalUserId, + setEditableExternalUserId, + accountId, + setAccountId, } = useAppState() + + // Check if proxy input editing is enabled via environment variable + const enableProxyInput = process.env.NEXT_PUBLIC_ENABLE_PROXY_INPUT === 'true' const id1 = useId(); const id2 = useId(); const [showAdvanced, setShowAdvanced] = useState(false); @@ -305,7 +312,7 @@ export const ConfigPanel = () => {
type{" "} componentType ={" "} - 'action' | 'trigger' + 'action' | 'trigger' | 'proxy'
@@ -334,48 +341,55 @@ export const ConfigPanel = () => { />
- - - { - app - ? setSelectedAppSlug(app.name_slug) - : removeSelectedAppSlug() - }} - /> - - - - - {selectedApp ? ( - { - comp - ? setSelectedComponentKey(comp.key) - : removeSelectedComponentKey() + {(selectedComponentType === "action" || selectedComponentType === "trigger" || selectedComponentType === "proxy") && ( + + + { + if (app) { + console.log('📱 App selected:', app) + setSelectedAppSlug(app.name_slug) + } else { + removeSelectedAppSlug() + } }} /> - ) : ( -
- Loading components... -
- )} -
-
+
+
+ )} + {selectedComponentType !== "proxy" && ( + + + {selectedApp ? ( + { + comp + ? setSelectedComponentKey(comp.key) + : removeSelectedComponentKey() + }} + /> + ) : ( +
+ Loading components... +
+ )} +
+
+ )} {selectedComponentType === "trigger" && ( { description="Authenticated user identifier" required={true} > - + {selectedComponentType === "proxy" ? ( + setEditableExternalUserId(e.target.value) : undefined} + placeholder={enableProxyInput ? "Enter external user ID" : "External user ID (read-only)"} + className={`w-full px-3 py-1.5 text-sm font-mono border rounded ${ + enableProxyInput + ? "bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" + : "bg-zinc-50/50" + }`} + readOnly={!enableProxyInput} + /> + ) : ( + + )} + {selectedApp && ( +
+
+ + + + + + +
+ Complete metadata for the selected app +
+
+
+
+
+
+
+
+                {JSON.stringify(selectedApp, null, 2)}
+              
+
+
+
+ )} + {selectedComponentType === "proxy" && enableProxyInput && ( + + setAccountId(e.target.value)} + placeholder="Enter account ID" + className="w-full px-3 py-1.5 text-sm font-mono border rounded bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" + /> + + )} ) @@ -566,29 +639,33 @@ export const ConfigPanel = () => { {basicFormControls} {/* Desktop: Show with section header */} -
-
-

Additional Config Options

- {advancedFormControls} + {selectedComponentType !== "proxy" && ( +
+
+

Additional Config Options

+ {advancedFormControls} +
-
+ )} {/* Mobile: Collapsible */} -
- - -
- - More options -
- -
- - - {advancedFormControls} - -
-
+ {selectedComponentType !== "proxy" && ( +
+ + +
+ + More options +
+ +
+ + + {advancedFormControls} + +
+
+ )} {triggerInfo}
diff --git a/connect-react-demo/app/components/DemoPanel.tsx b/connect-react-demo/app/components/DemoPanel.tsx index 85d78d5..1d9a218 100644 --- a/connect-react-demo/app/components/DemoPanel.tsx +++ b/connect-react-demo/app/components/DemoPanel.tsx @@ -4,6 +4,7 @@ import type { ConfigurableProps } from "@pipedream/sdk" import { useAppState } from "@/lib/app-state" import { PageSkeleton } from "./PageSkeleton" import { TerminalCollapsible } from "./TerminalCollapsible" +import { ProxyRequestBuilder } from "./ProxyRequestBuilder" export const DemoPanel = () => { const frontendClient = useFrontendClient() @@ -21,6 +22,11 @@ export const DemoPanel = () => { webhookUrl, enableDebugging, setWebhookUrlValidationAttempted, + selectedApp, + accountId, + setAccountId, + editableExternalUserId, + setEditableExternalUserId, } = useAppState() const [dynamicPropsId, setDynamicPropsId] = useState() @@ -176,23 +182,68 @@ export const DemoPanel = () => { >
- - {selectedComponentKey && ( - - )} - + {selectedComponentType === "proxy" ? ( + // For proxy: show connect flow if app selected but no account, otherwise show proxy form + selectedApp && !accountId ? ( +
+

Connect {selectedApp.name}

+

Connect your {selectedApp.name} account to start making API requests

+ + {sdkErrors && ( +
+ {String(sdkErrors)} +
+ )} +
+ ) : accountId ? ( + + ) : ( +
+ Select an app to get started with proxy requests +
+ ) + ) : ( + + {selectedComponentKey && ( + + )} + + )}
diff --git a/connect-react-demo/app/components/ProxyRequestBuilder.tsx b/connect-react-demo/app/components/ProxyRequestBuilder.tsx new file mode 100644 index 0000000..e474f88 --- /dev/null +++ b/connect-react-demo/app/components/ProxyRequestBuilder.tsx @@ -0,0 +1,283 @@ +import { useState, useEffect } from "react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Label } from "@/components/ui/label" +import { useAppState } from "@/lib/app-state" +import { proxyRequest } from "@/app/actions/backendClient" + +const HTTP_METHODS = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS" +] as const + +export function ProxyRequestBuilder() { + const { + proxyUrl, + setProxyUrl, + proxyMethod, + setProxyMethod, + proxyBody, + setProxyBody, + editableExternalUserId, + accountId, + selectedApp + } = useAppState() + + const [isLoading, setIsLoading] = useState(false) + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + + // Auto-fill proxy URL when app is selected + useEffect(() => { + if (selectedApp?.connect?.base_proxy_target_url && !proxyUrl) { + setProxyUrl(selectedApp.connect.base_proxy_target_url) + } + }, [selectedApp, proxyUrl, setProxyUrl]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!proxyUrl.trim()) { + setError("URL is required") + return + } + + if (!editableExternalUserId?.trim()) { + setError("External User ID is required") + return + } + + if (!accountId?.trim()) { + setError("Account ID is required") + return + } + + + setIsLoading(true) + setError(null) + setResponse(null) + + try { + // Parse body if it's provided for POST/PUT/PATCH requests + let parsedBody: any = undefined + if (proxyBody.trim() && ["POST", "PUT", "PATCH"].includes(proxyMethod)) { + try { + parsedBody = JSON.parse(proxyBody) + } catch (parseError) { + setError("Invalid JSON in request body") + setIsLoading(false) + return + } + } + + // Prepare the proxy request object + const requestObject = { + externalUserId: editableExternalUserId, + accountId: accountId, + url: proxyUrl, + method: proxyMethod, + ...(parsedBody && { data: parsedBody }) + } + + // Log the request object to console + console.log('🔄 Sending proxy request:', requestObject) + + // Make the actual proxy request using server action + const proxyResponse = await proxyRequest(requestObject) + + setResponse({ + status: proxyResponse.status || 200, + data: proxyResponse.data, + headers: proxyResponse.headers || {}, + request: { + url: proxyUrl, + method: proxyMethod, + body: parsedBody, + externalUserId: editableExternalUserId, + accountId + } + }) + } catch (err: any) { + setError(err?.message || "Request failed") + + // If there's response data in the error, show it + if (err?.status || err?.data) { + setResponse({ + status: err.status || 500, + error: true, + data: err.data, + headers: err.headers, + request: { + url: proxyUrl, + method: proxyMethod, + body: parsedBody, + externalUserId: editableExternalUserId, + accountId + } + }) + } + } finally { + setIsLoading(false) + } + } + + const showBodyField = ["POST", "PUT", "PATCH"].includes(proxyMethod) + + return ( +
+
+
+

API Request Builder

+

+ Make direct API requests through your authenticated account. +

+
+ +
+
+ + setProxyUrl(e.target.value)} + placeholder="https://api.example.com/endpoint or /api/v1/users" + className="font-mono text-sm" + /> +

+ Enter a full URL or a path (e.g., /api/v1/users) +

+
+ +
+ + +
+ + {showBodyField && ( +
+ +