mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
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:
parent
2326a2f65a
commit
49fcb770a4
4 changed files with 142 additions and 0 deletions
|
|
@ -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 {})
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue