feat: support {{variable}} substitution in HTTP tool endpoint URL

Apply the same render_template() logic used for node prompts and preset
parameters to the endpoint URL, so users can use {{initial_context.*}}
and {{gathered_context.*}} in the URL path. The URL is rendered at
execution time just before the HTTP request.

- Backend: render URL via render_template() in execute_http_tool
- Frontend: allow {{variable}} in URL path but reject in domain
- Frontend: add hint label below Endpoint URL field
- Tests: verify URL rendering with initial_context, gathered_context,
  and static (unchanged) URLs
This commit is contained in:
XI 2026-06-03 02:35:43 +01:00
parent 2326a2f65a
commit 49fcb770a4
4 changed files with 142 additions and 0 deletions

View file

@ -231,6 +231,17 @@ async def execute_http_tool(
method = config.get("method", "POST").upper()
url = config.get("url", "")
# Build render context for template variable substitution (same as preset params)
initial_context = dict(call_context_vars or {})
render_context: Dict[str, Any] = {
**initial_context,
"initial_context": initial_context,
"gathered_context": dict(gathered_context_vars or {}),
}
# Apply template rendering to URL (supports {{variable}} in path)
url = render_template(url, render_context)
# Get headers from config
headers = dict(config.get("headers", {}) or {})

View file

@ -730,6 +730,116 @@ class TestExecuteHttpTool:
# Verify credential lookup was NOT called
mock_db.get_credential_by_uuid.assert_not_called()
@pytest.mark.asyncio
async def test_url_with_initial_context_variable(self):
"""Test that {{{{}}}} in URL path is resolved from initial_context."""
tool = MockToolModel(
tool_uuid="test-uuid-url-var",
name="Dynamic URL",
description="API with URL template",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
"config": {
"method": "GET",
"url": "https://api.example.com/{{initial_context.resource}}/details",
"timeout_ms": 5000,
},
},
)
with patch(
"api.services.workflow.tools.custom_tool.httpx.AsyncClient"
) as mock_client_class:
mock_client = AsyncMock()
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1}
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
await execute_http_tool(
tool,
{},
call_context_vars={"resource": "orders"},
)
call_kwargs = mock_client.request.call_args.kwargs
assert call_kwargs["url"] == "https://api.example.com/orders/details"
@pytest.mark.asyncio
async def test_url_with_gathered_context_variable(self):
"""Test that {{{{}}}} in URL path is resolved from gathered_context."""
tool = MockToolModel(
tool_uuid="test-uuid-url-gathered",
name="Dynamic URL Gathered",
description="API with URL template from gathered context",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
"config": {
"method": "GET",
"url": "https://api.example.com/customers/{{gathered_context.customer_id}}/profile",
"timeout_ms": 5000,
},
},
)
with patch(
"api.services.workflow.tools.custom_tool.httpx.AsyncClient"
) as mock_client_class:
mock_client = AsyncMock()
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"name": "John"}
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
await execute_http_tool(
tool,
{},
gathered_context_vars={"customer_id": "42"},
)
call_kwargs = mock_client.request.call_args.kwargs
assert call_kwargs["url"] == "https://api.example.com/customers/42/profile"
@pytest.mark.asyncio
async def test_url_without_template_variables_unchanged(self):
"""Test that URLs without template variables are passed through unchanged."""
tool = MockToolModel(
tool_uuid="test-uuid-no-var",
name="Static URL",
description="API with static URL",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
"config": {
"method": "GET",
"url": "https://api.example.com/static/endpoint",
"timeout_ms": 5000,
},
},
)
with patch(
"api.services.workflow.tools.custom_tool.httpx.AsyncClient"
) as mock_client_class:
mock_client = AsyncMock()
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {}
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
await execute_http_tool(tool, {})
call_kwargs = mock_client.request.call_args.kwargs
assert call_kwargs["url"] == "https://api.example.com/static/endpoint"
class TestCoerceParameterValue:
"""Tests for _coerce_parameter_value function."""

View file

@ -141,6 +141,9 @@ export function HttpApiToolConfig({
<div className="grid gap-2">
<Label>Endpoint URL</Label>
<Label className="text-xs text-muted-foreground">
Supports {`{{variable}}`} syntax using initial and gathered context variables in the URL path
</Label>
<UrlInput
value={url}
onChange={onUrlChange}

View file

@ -19,6 +19,12 @@ export interface UrlValidationResult {
error?: string;
}
const DOMAIN_REGEX = /^https?:\/\/([^\/]+)/;
function domainContainsTemplateVar(domain: string): boolean {
return domain.includes("{{") || domain.includes("}}");
}
export function validateUrl(url: string): UrlValidationResult {
const trimmedUrl = url.trim();
@ -26,6 +32,18 @@ export function validateUrl(url: string): UrlValidationResult {
return { valid: false, error: "URL is required" };
}
// If the URL contains template variables, validate domain separately
if (trimmedUrl.includes("{{")) {
const domainMatch = trimmedUrl.match(DOMAIN_REGEX);
if (!domainMatch || domainContainsTemplateVar(domainMatch[1])) {
return {
valid: false,
error: "Invalid URL format. Template variables are only allowed in the URL path.",
};
}
return { valid: true };
}
if (!URL_REGEX.test(trimmedUrl)) {
return {
valid: false,