mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-04-19 18:27:33 +08:00
Feat: support variable interpolation in headers (#13680)
Closes #13277 ### What problem does this PR solve? Adds `{variable_name}` (and `{component@variable}`) interpolation support to HTTP header values in the `Invoke` component, matching the existing URL interpolation behavior. ### Type of change - [x] New Feature (non-breaking change which adds functionality) <img width="1280" height="867" alt="image" src="https://github.com/user-attachments/assets/8ab7b4e9-7cc0-4a7f-8a5f-f838a15a5fda" /> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@ -97,8 +97,10 @@ class Invoke(ComponentBase, ABC):
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
variable_pattern = r"\{([a-zA-Z_][a-zA-Z0-9_.@-]*)\}"
|
||||
|
||||
# {base_url} or {component_id@variable_name}
|
||||
url = re.sub(r"\{([a-zA-Z_][a-zA-Z0-9_.@-]*)\}", replace_variable, url)
|
||||
url = re.sub(variable_pattern, replace_variable, url)
|
||||
|
||||
if url.find("http") != 0:
|
||||
url = "http://" + url
|
||||
@ -106,7 +108,25 @@ class Invoke(ComponentBase, ABC):
|
||||
method = self._param.method.lower()
|
||||
headers = {}
|
||||
if self._param.headers:
|
||||
headers = json.loads(self._param.headers)
|
||||
try:
|
||||
parsed_headers = json.loads(self._param.headers)
|
||||
except json.JSONDecodeError as e:
|
||||
logging.warning(
|
||||
"Invoke headers are not valid JSON, ignoring headers. raw=%r error=%s",
|
||||
self._param.headers,
|
||||
e,
|
||||
)
|
||||
parsed_headers = {}
|
||||
if not isinstance(parsed_headers, dict):
|
||||
logging.warning(
|
||||
"Invoke headers JSON is of type %s, expected an object; ignoring headers.",
|
||||
type(parsed_headers).__name__,
|
||||
)
|
||||
parsed_headers = {}
|
||||
headers = parsed_headers
|
||||
for key, value in list(headers.items()):
|
||||
if isinstance(value, str):
|
||||
headers[key] = re.sub(variable_pattern, replace_variable, value)
|
||||
proxies = None
|
||||
if re.sub(r"https?:?/?/?", "", self._param.proxy):
|
||||
proxies = {"http": self._param.proxy, "https": self._param.proxy}
|
||||
|
||||
@ -0,0 +1,273 @@
|
||||
#
|
||||
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""
|
||||
Unit tests for the Invoke component's header variable interpolation.
|
||||
|
||||
These tests exercise the real Invoke._invoke method, verifying that
|
||||
{variable} placeholders in HTTP header values are resolved via canvas
|
||||
variable lookup (issue #13277).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_invoke_module(monkeypatch):
|
||||
"""Load the real Invoke class with monkeypatched stubs that are
|
||||
automatically cleaned up after each test."""
|
||||
repo_root = Path(__file__).resolve().parents[4]
|
||||
|
||||
# -- lightweight stubs (auto-restored by monkeypatch) --------------------
|
||||
|
||||
quart = ModuleType("quart")
|
||||
quart.make_response = lambda *a, **kw: None
|
||||
quart.jsonify = lambda *a, **kw: None
|
||||
monkeypatch.setitem(sys.modules, "quart", quart)
|
||||
|
||||
pd = ModuleType("pandas")
|
||||
pd.DataFrame = type("DataFrame", (), {})
|
||||
monkeypatch.setitem(sys.modules, "pandas", pd)
|
||||
|
||||
deepdoc = ModuleType("deepdoc")
|
||||
deepdoc.__path__ = []
|
||||
monkeypatch.setitem(sys.modules, "deepdoc", deepdoc)
|
||||
deepdoc_parser = ModuleType("deepdoc.parser")
|
||||
deepdoc_parser.HtmlParser = MagicMock
|
||||
monkeypatch.setitem(sys.modules, "deepdoc.parser", deepdoc_parser)
|
||||
monkeypatch.setitem(sys.modules, "xgboost", ModuleType("xgboost"))
|
||||
|
||||
# -- common package and submodules ---------------------------------------
|
||||
|
||||
common_pkg = ModuleType("common")
|
||||
common_pkg.__path__ = [str(repo_root / "common")]
|
||||
monkeypatch.setitem(sys.modules, "common", common_pkg)
|
||||
|
||||
constants = ModuleType("common.constants")
|
||||
|
||||
class _RetCode:
|
||||
SUCCESS = 0
|
||||
EXCEPTION_ERROR = 100
|
||||
|
||||
constants.RetCode = _RetCode
|
||||
monkeypatch.setitem(sys.modules, "common.constants", constants)
|
||||
|
||||
conn_spec = importlib.util.spec_from_file_location("common.connection_utils", repo_root / "common" / "connection_utils.py")
|
||||
conn_mod = importlib.util.module_from_spec(conn_spec)
|
||||
monkeypatch.setitem(sys.modules, "common.connection_utils", conn_mod)
|
||||
conn_spec.loader.exec_module(conn_mod)
|
||||
|
||||
misc_spec = importlib.util.spec_from_file_location("common.misc_utils", repo_root / "common" / "misc_utils.py")
|
||||
misc_mod = importlib.util.module_from_spec(misc_spec)
|
||||
monkeypatch.setitem(sys.modules, "common.misc_utils", misc_mod)
|
||||
misc_spec.loader.exec_module(misc_mod)
|
||||
|
||||
# -- agent package (bare stubs to skip __init__ auto-import) -------------
|
||||
|
||||
agent_pkg = ModuleType("agent")
|
||||
agent_pkg.__path__ = [str(repo_root / "agent")]
|
||||
monkeypatch.setitem(sys.modules, "agent", agent_pkg)
|
||||
|
||||
agent_settings = ModuleType("agent.settings")
|
||||
agent_settings.FLOAT_ZERO = 1e-8
|
||||
agent_settings.PARAM_MAXDEPTH = 5
|
||||
monkeypatch.setitem(sys.modules, "agent.settings", agent_settings)
|
||||
|
||||
component_pkg = ModuleType("agent.component")
|
||||
component_pkg.__path__ = [str(repo_root / "agent" / "component")]
|
||||
monkeypatch.setitem(sys.modules, "agent.component", component_pkg)
|
||||
|
||||
# -- load the real base.py and invoke.py ---------------------------------
|
||||
|
||||
base_spec = importlib.util.spec_from_file_location("agent.component.base", repo_root / "agent" / "component" / "base.py")
|
||||
base_mod = importlib.util.module_from_spec(base_spec)
|
||||
monkeypatch.setitem(sys.modules, "agent.component.base", base_mod)
|
||||
base_spec.loader.exec_module(base_mod)
|
||||
|
||||
invoke_spec = importlib.util.spec_from_file_location("agent.component.invoke", repo_root / "agent" / "component" / "invoke.py")
|
||||
invoke_mod = importlib.util.module_from_spec(invoke_spec)
|
||||
monkeypatch.setitem(sys.modules, "agent.component.invoke", invoke_mod)
|
||||
invoke_spec.loader.exec_module(invoke_mod)
|
||||
|
||||
return invoke_mod
|
||||
|
||||
|
||||
def _make_invoke(module, *, url="http://example.com", method="get", headers="", variables=None, proxy="", timeout_sec=60, clean_html=False, datatype="json", variable_values=None):
|
||||
"""Build an Invoke instance with a mocked canvas."""
|
||||
variable_values = variable_values or {}
|
||||
|
||||
canvas = MagicMock()
|
||||
canvas.get_variable_value = MagicMock(side_effect=lambda k: variable_values.get(k, ""))
|
||||
canvas.is_canceled = MagicMock(return_value=False)
|
||||
|
||||
param = module.InvokeParam.__new__(module.InvokeParam)
|
||||
param.url = url
|
||||
param.method = method
|
||||
param.headers = headers
|
||||
param.variables = variables or []
|
||||
param.proxy = proxy
|
||||
param.timeout = timeout_sec
|
||||
param.clean_html = clean_html
|
||||
param.datatype = datatype
|
||||
param.max_retries = 0
|
||||
param.delay_after_error = 0
|
||||
param.outputs = {}
|
||||
param.inputs = {}
|
||||
|
||||
inst = module.Invoke.__new__(module.Invoke)
|
||||
inst._canvas = canvas
|
||||
inst._param = param
|
||||
inst._id = "invoke_test"
|
||||
|
||||
return inst
|
||||
|
||||
|
||||
@pytest.mark.p2
|
||||
def test_header_single_variable(monkeypatch):
|
||||
module = _load_invoke_module(monkeypatch)
|
||||
invoke = _make_invoke(
|
||||
module,
|
||||
headers=json.dumps({"Authorization": "Bearer {auth_token}"}),
|
||||
variable_values={"auth_token": "secret123"},
|
||||
)
|
||||
mock_get = MagicMock(return_value=SimpleNamespace(text="ok"))
|
||||
monkeypatch.setattr(module.requests, "get", mock_get)
|
||||
invoke._invoke()
|
||||
assert mock_get.call_args[1]["headers"]["Authorization"] == "Bearer secret123"
|
||||
|
||||
|
||||
@pytest.mark.p2
|
||||
def test_header_multiple_variables(monkeypatch):
|
||||
module = _load_invoke_module(monkeypatch)
|
||||
invoke = _make_invoke(
|
||||
module,
|
||||
headers=json.dumps(
|
||||
{
|
||||
"Authorization": "Bearer {token}",
|
||||
"X-Request-Id": "{req_id}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
),
|
||||
variable_values={"token": "tok_abc", "req_id": "id-42"},
|
||||
)
|
||||
mock_get = MagicMock(return_value=SimpleNamespace(text="ok"))
|
||||
monkeypatch.setattr(module.requests, "get", mock_get)
|
||||
invoke._invoke()
|
||||
h = mock_get.call_args[1]["headers"]
|
||||
assert h["Authorization"] == "Bearer tok_abc"
|
||||
assert h["X-Request-Id"] == "id-42"
|
||||
assert h["Content-Type"] == "application/json"
|
||||
|
||||
|
||||
@pytest.mark.p2
|
||||
def test_header_no_variables_unchanged(monkeypatch):
|
||||
module = _load_invoke_module(monkeypatch)
|
||||
invoke = _make_invoke(
|
||||
module,
|
||||
headers=json.dumps({"Content-Type": "application/json"}),
|
||||
)
|
||||
mock_get = MagicMock(return_value=SimpleNamespace(text="ok"))
|
||||
monkeypatch.setattr(module.requests, "get", mock_get)
|
||||
invoke._invoke()
|
||||
assert mock_get.call_args[1]["headers"]["Content-Type"] == "application/json"
|
||||
|
||||
|
||||
@pytest.mark.p2
|
||||
def test_header_empty(monkeypatch):
|
||||
module = _load_invoke_module(monkeypatch)
|
||||
invoke = _make_invoke(module, headers="")
|
||||
mock_get = MagicMock(return_value=SimpleNamespace(text="ok"))
|
||||
monkeypatch.setattr(module.requests, "get", mock_get)
|
||||
invoke._invoke()
|
||||
assert mock_get.call_args[1]["headers"] == {}
|
||||
|
||||
|
||||
@pytest.mark.p2
|
||||
def test_header_component_ref_variable(monkeypatch):
|
||||
module = _load_invoke_module(monkeypatch)
|
||||
invoke = _make_invoke(
|
||||
module,
|
||||
headers=json.dumps({"Authorization": "Bearer {begin@token}"}),
|
||||
variable_values={"begin@token": "my_token"},
|
||||
)
|
||||
mock_get = MagicMock(return_value=SimpleNamespace(text="ok"))
|
||||
monkeypatch.setattr(module.requests, "get", mock_get)
|
||||
invoke._invoke()
|
||||
assert mock_get.call_args[1]["headers"]["Authorization"] == "Bearer my_token"
|
||||
|
||||
|
||||
@pytest.mark.p2
|
||||
def test_header_env_variable(monkeypatch):
|
||||
module = _load_invoke_module(monkeypatch)
|
||||
invoke = _make_invoke(
|
||||
module,
|
||||
headers=json.dumps({"Authorization": "Bearer {env.api_key}"}),
|
||||
variable_values={"env.api_key": "env_secret"},
|
||||
)
|
||||
mock_get = MagicMock(return_value=SimpleNamespace(text="ok"))
|
||||
monkeypatch.setattr(module.requests, "get", mock_get)
|
||||
invoke._invoke()
|
||||
assert mock_get.call_args[1]["headers"]["Authorization"] == "Bearer env_secret"
|
||||
|
||||
|
||||
@pytest.mark.p2
|
||||
def test_header_missing_variable_becomes_empty(monkeypatch):
|
||||
module = _load_invoke_module(monkeypatch)
|
||||
invoke = _make_invoke(
|
||||
module,
|
||||
headers=json.dumps({"Authorization": "Bearer {nonexistent}"}),
|
||||
variable_values={},
|
||||
)
|
||||
mock_get = MagicMock(return_value=SimpleNamespace(text="ok"))
|
||||
monkeypatch.setattr(module.requests, "get", mock_get)
|
||||
invoke._invoke()
|
||||
assert mock_get.call_args[1]["headers"]["Authorization"] == "Bearer "
|
||||
|
||||
|
||||
@pytest.mark.p2
|
||||
def test_header_variable_with_post(monkeypatch):
|
||||
module = _load_invoke_module(monkeypatch)
|
||||
invoke = _make_invoke(
|
||||
module,
|
||||
method="post",
|
||||
headers=json.dumps({"Authorization": "Bearer {token}"}),
|
||||
variable_values={"token": "post_token"},
|
||||
)
|
||||
mock_post = MagicMock(return_value=SimpleNamespace(text="ok"))
|
||||
monkeypatch.setattr(module.requests, "post", mock_post)
|
||||
invoke._invoke()
|
||||
assert mock_post.call_args[1]["headers"]["Authorization"] == "Bearer post_token"
|
||||
|
||||
|
||||
@pytest.mark.p2
|
||||
def test_header_variable_with_put(monkeypatch):
|
||||
module = _load_invoke_module(monkeypatch)
|
||||
invoke = _make_invoke(
|
||||
module,
|
||||
method="put",
|
||||
headers=json.dumps({"Authorization": "Bearer {token}"}),
|
||||
variable_values={"token": "put_token"},
|
||||
)
|
||||
mock_put = MagicMock(return_value=SimpleNamespace(text="ok"))
|
||||
monkeypatch.setattr(module.requests, "put", mock_put)
|
||||
invoke._invoke()
|
||||
assert mock_put.call_args[1]["headers"]["Authorization"] == "Bearer put_token"
|
||||
Reference in New Issue
Block a user