ae67bff5a3
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
108 lines
4.7 KiB
Python
108 lines
4.7 KiB
Python
"""Per-environment endpoint substitution ({{env.*}} from FORGE_TOOL_VARS / settings.tool_vars).
|
|
|
|
A tool/auth endpoint template references {{env.<key>}}; the value is supplied per environment via
|
|
the tool_vars setting, so the SAME tool DB row resolves to a different real host in dev/qa/prod.
|
|
Unlike {{ctx.*}} (lenient - a missing value is dropped/empty), a missing {{env.*}} key FAILS the
|
|
call loudly (MissingTemplateVar), so a misconfigured environment never sends a broken URL.
|
|
"""
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
import forge.tools.graphql as gql_mod
|
|
import forge.tools.rest as rest_mod
|
|
from forge.auth_providers.templates import MissingTemplateVar
|
|
from forge.tools.graphql import execute_graphql
|
|
from forge.tools.rest import execute_rest
|
|
|
|
|
|
def _capturing_client(sink: dict) -> httpx.AsyncClient:
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
sink["request"] = request
|
|
return httpx.Response(200, json={"ok": True})
|
|
|
|
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
|
|
|
|
async def test_env_substitution_in_url(monkeypatch):
|
|
"""{{env.*}} in a url_template resolves from settings.tool_vars to the env's real host."""
|
|
monkeypatch.setattr(rest_mod.settings, "tool_vars", {"api_base": "https://api.qa.example.com"})
|
|
sink: dict = {}
|
|
cfg = {
|
|
"name": "orders_get",
|
|
"request": {
|
|
"method": "GET",
|
|
"url_template": "{{env.api_base}}/v1/orders/{id}",
|
|
"fields": [{"path": "id", "type": "string", "in": "path", "required": True, "llm_visible": True}],
|
|
"headers": [],
|
|
},
|
|
}
|
|
async with _capturing_client(sink) as client:
|
|
await execute_rest(cfg, {"id": "O-1"}, tenant_id="t", project_id="p", client=client)
|
|
assert str(sink["request"].url) == "https://api.qa.example.com/v1/orders/O-1"
|
|
|
|
|
|
async def test_env_substitution_in_body_template(monkeypatch):
|
|
"""{{env.*}} works alongside {{input.*}} in a JSON body template."""
|
|
monkeypatch.setattr(rest_mod.settings, "tool_vars", {"tenant": "acme-qa"})
|
|
sink: dict = {}
|
|
cfg = {
|
|
"name": "order_add",
|
|
"request": {
|
|
"method": "POST",
|
|
"url_template": "https://portal.example.dev/orders",
|
|
"fields": [{"path": "amount", "type": "integer", "in": "body", "llm_visible": True}],
|
|
"headers": [],
|
|
"body_template": '{"amount": {{ input.amount }}, "tenant": "{{ env.tenant }}"}',
|
|
},
|
|
}
|
|
async with _capturing_client(sink) as client:
|
|
await execute_rest(cfg, {"amount": 5}, tenant_id="t", project_id="p", client=client)
|
|
assert json.loads(sink["request"].content) == {"amount": 5, "tenant": "acme-qa"}
|
|
|
|
|
|
async def test_undefined_env_var_fails_loud(monkeypatch):
|
|
"""A template referencing an env var absent from tool_vars raises (never a broken request)."""
|
|
monkeypatch.setattr(rest_mod.settings, "tool_vars", {}) # nothing defined for this env
|
|
sink: dict = {}
|
|
cfg = {
|
|
"name": "x",
|
|
"request": {"method": "GET", "url_template": "{{env.api_base}}/x", "fields": [], "headers": []},
|
|
}
|
|
async with _capturing_client(sink) as client:
|
|
with pytest.raises(MissingTemplateVar) as e:
|
|
await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
|
assert "api_base" in str(e.value)
|
|
assert "request" not in sink # the call never went out
|
|
|
|
|
|
async def test_ctx_stays_lenient_alongside_strict_env(monkeypatch):
|
|
"""env is strict, but ctx keeps its lenient behavior (missing -> empty) in the same template."""
|
|
monkeypatch.setattr(rest_mod.settings, "tool_vars", {"base": "https://api.example.com"})
|
|
sink: dict = {}
|
|
cfg = {
|
|
"name": "x",
|
|
"request": {
|
|
"method": "GET",
|
|
"url_template": "{{env.base}}/x?tok={{ctx.absent}}",
|
|
"fields": [],
|
|
"headers": [],
|
|
},
|
|
}
|
|
async with _capturing_client(sink) as client:
|
|
await execute_rest(cfg, {}, tenant_id="t", project_id="p", context={}, client=client)
|
|
# env resolved; the missing ctx token rendered empty rather than raising.
|
|
assert str(sink["request"].url) == "https://api.example.com/x?tok="
|
|
|
|
|
|
async def test_graphql_endpoint_env_substitution(monkeypatch):
|
|
"""The GraphQL endpoint (previously used verbatim) now resolves {{env.*}} too."""
|
|
monkeypatch.setattr(gql_mod.settings, "tool_vars", {"gql_base": "https://gql.prod.example.com"})
|
|
sink: dict = {}
|
|
cfg = {"endpoint": "{{env.gql_base}}/graphql", "query": "{ ping }", "variables": []}
|
|
async with _capturing_client(sink) as client:
|
|
await execute_graphql(cfg, {}, tenant_id="t", project_id="p", client=client)
|
|
assert str(sink["request"].url) == "https://gql.prod.example.com/graphql"
|