Refa: Searches /search API to RESTFul (#13770)

### What problem does this PR solve?

Searches /search API to RESTFul

### Type of change

- [x] Documentation Update
- [x] Refactoring

Co-authored-by: Jin Hai <haijin.chn@gmail.com>
Co-authored-by: Yingfeng <yingfeng.zhang@gmail.com>
This commit is contained in:
Yongteng Lei
2026-03-26 01:07:41 +08:00
committed by GitHub
parent ea1430bec5
commit d19ca71b43
10 changed files with 494 additions and 218 deletions

View File

@ -39,7 +39,7 @@ API_APP_URL = f"/{VERSION}/api"
SYSTEM_APP_URL = f"/{VERSION}/system"
LLM_APP_URL = f"/{VERSION}/llm"
PLUGIN_APP_URL = f"/{VERSION}/plugin"
SEARCH_APP_URL = f"/{VERSION}/search"
SEARCHES_URL = f"/api/{VERSION}/searches"
def _http_debug_enabled():
@ -142,29 +142,27 @@ def plugin_llm_tools(auth, params=None, *, headers=HEADERS):
# SEARCH APP
def search_create(auth, payload=None, *, headers=HEADERS, data=None):
res = requests.post(url=f"{HOST_ADDRESS}{SEARCH_APP_URL}/create", headers=headers, auth=auth, json=payload, data=data)
res = requests.post(url=f"{HOST_ADDRESS}{SEARCHES_URL}", headers=headers, auth=auth, json=payload, data=data)
return res.json()
def search_update(auth, payload=None, *, headers=HEADERS, data=None):
res = requests.post(url=f"{HOST_ADDRESS}{SEARCH_APP_URL}/update", headers=headers, auth=auth, json=payload, data=data)
def search_update(auth, search_id, payload=None, *, headers=HEADERS, data=None):
res = requests.put(url=f"{HOST_ADDRESS}{SEARCHES_URL}/{search_id}", headers=headers, auth=auth, json=payload, data=data)
return res.json()
def search_detail(auth, params=None, *, headers=HEADERS):
res = requests.get(url=f"{HOST_ADDRESS}{SEARCH_APP_URL}/detail", headers=headers, auth=auth, params=params)
def search_detail(auth, search_id, *, headers=HEADERS):
res = requests.get(url=f"{HOST_ADDRESS}{SEARCHES_URL}/{search_id}", headers=headers, auth=auth)
return res.json()
def search_list(auth, params=None, payload=None, *, headers=HEADERS, data=None):
if payload is None:
payload = {}
res = requests.post(url=f"{HOST_ADDRESS}{SEARCH_APP_URL}/list", headers=headers, auth=auth, params=params, json=payload, data=data)
def search_list(auth, params=None, *, headers=HEADERS):
res = requests.get(url=f"{HOST_ADDRESS}{SEARCHES_URL}", headers=headers, auth=auth, params=params)
return res.json()
def search_rm(auth, payload=None, *, headers=HEADERS, data=None):
res = requests.post(url=f"{HOST_ADDRESS}{SEARCH_APP_URL}/rm", headers=headers, auth=auth, json=payload, data=data)
def search_rm(auth, search_id, *, headers=HEADERS):
res = requests.delete(url=f"{HOST_ADDRESS}{SEARCHES_URL}/{search_id}", headers=headers, auth=auth)
return res.json()

View File

@ -31,15 +31,6 @@ def _search_name(prefix="search"):
return f"{prefix}_{uuid.uuid4().hex[:8]}"
def _find_tenant_id(WebApiAuth, search_id):
res = search_list(WebApiAuth, payload={})
assert res["code"] == 0, res
for search_app in res["data"]["search_apps"]:
if search_app.get("id") == search_id:
return search_app.get("tenant_id")
assert False, res
@pytest.fixture
def search_app(WebApiAuth):
name = _search_name()
@ -47,7 +38,7 @@ def search_app(WebApiAuth):
assert create_res["code"] == 0, create_res
search_id = create_res["data"]["search_id"]
yield search_id
rm_res = search_rm(WebApiAuth, {"search_id": search_id})
rm_res = search_rm(WebApiAuth, search_id)
assert rm_res["code"] == 0, rm_res
assert rm_res["data"] is True, rm_res
@ -63,28 +54,28 @@ class TestAuthorization:
@pytest.mark.p2
@pytest.mark.parametrize("invalid_auth, expected_code, expected_fragment", INVALID_AUTH_CASES)
def test_auth_invalid_list(self, invalid_auth, expected_code, expected_fragment):
res = search_list(invalid_auth, payload={})
res = search_list(invalid_auth)
assert res["code"] == expected_code, res
assert expected_fragment in res["message"], res
@pytest.mark.p2
@pytest.mark.parametrize("invalid_auth, expected_code, expected_fragment", INVALID_AUTH_CASES)
def test_auth_invalid_detail(self, invalid_auth, expected_code, expected_fragment):
res = search_detail(invalid_auth, {"search_id": "dummy_search_id"})
res = search_detail(invalid_auth, "dummy_search_id")
assert res["code"] == expected_code, res
assert expected_fragment in res["message"], res
@pytest.mark.p2
@pytest.mark.parametrize("invalid_auth, expected_code, expected_fragment", INVALID_AUTH_CASES)
def test_auth_invalid_update(self, invalid_auth, expected_code, expected_fragment):
res = search_update(invalid_auth, {"search_id": "dummy", "name": "dummy", "search_config": {}, "tenant_id": "dummy"})
res = search_update(invalid_auth, "dummy", {"name": "dummy", "search_config": {}})
assert res["code"] == expected_code, res
assert expected_fragment in res["message"], res
@pytest.mark.p2
@pytest.mark.parametrize("invalid_auth, expected_code, expected_fragment", INVALID_AUTH_CASES)
def test_auth_invalid_rm(self, invalid_auth, expected_code, expected_fragment):
res = search_rm(invalid_auth, {"search_id": "dummy_search_id"})
res = search_rm(invalid_auth, "dummy_search_id")
assert res["code"] == expected_code, res
assert expected_fragment in res["message"], res
@ -97,33 +88,26 @@ class TestSearchCrud:
assert create_res["code"] == 0, create_res
search_id = create_res["data"]["search_id"]
rm_res = search_rm(WebApiAuth, {"search_id": search_id})
rm_res = search_rm(WebApiAuth, search_id)
assert rm_res["code"] == 0, rm_res
assert rm_res["data"] is True, rm_res
@pytest.mark.p2
def test_list(self, WebApiAuth, search_app):
res = search_list(WebApiAuth, payload={})
res = search_list(WebApiAuth)
assert res["code"] == 0, res
assert any(app.get("id") == search_app for app in res["data"]["search_apps"]), res
@pytest.mark.p2
def test_detail(self, WebApiAuth, search_app):
res = search_detail(WebApiAuth, {"search_id": search_app})
res = search_detail(WebApiAuth, search_app)
assert res["code"] == 0, res
assert res["data"].get("id") == search_app, res
@pytest.mark.p2
def test_update(self, WebApiAuth, search_app):
tenant_id = _find_tenant_id(WebApiAuth, search_app)
new_name = _search_name("updated")
payload = {
"search_id": search_app,
"name": new_name,
"search_config": {"top_k": 3},
"tenant_id": tenant_id,
}
res = search_update(WebApiAuth, payload)
res = search_update(WebApiAuth, search_app, {"name": new_name, "search_config": {"top_k": 3}})
assert res["code"] == 0, res
assert res["data"].get("name") == new_name, res
@ -138,17 +122,10 @@ class TestSearchCrud:
create_res = search_create(WebApiAuth, {"name": _search_name("invalid"), "description": "test search"})
assert create_res["code"] == 0, create_res
search_id = create_res["data"]["search_id"]
tenant_id = _find_tenant_id(WebApiAuth, search_id)
try:
payload = {
"search_id": "invalid_search_id",
"name": "invalid",
"search_config": {},
"tenant_id": tenant_id,
}
res = search_update(WebApiAuth, payload)
res = search_update(WebApiAuth, "invalid_search_id", {"name": "invalid", "search_config": {}})
assert res["code"] == 109, res
assert "No authorization" in res["message"], res
finally:
rm_res = search_rm(WebApiAuth, {"search_id": search_id})
rm_res = search_rm(WebApiAuth, search_id)
assert rm_res["code"] == 0, rm_res

View File

@ -44,6 +44,14 @@ class _Args(dict):
def get(self, key, default=None):
return super().get(key, default)
def getlist(self, key):
val = self.get(key)
if val is None:
return []
if isinstance(val, list):
return val
return [val]
class _EnumValue:
def __init__(self, value):
@ -98,7 +106,7 @@ def set_tenant_info():
return None
def _load_search_app(monkeypatch):
def _load_search_api(monkeypatch):
repo_root = Path(__file__).resolve().parents[4]
quart_mod = ModuleType("quart")
@ -233,23 +241,16 @@ def _load_search_app(monkeypatch):
return _decorator
def _not_allowed_parameters(*_params):
def _decorator(func):
return func
return _decorator
api_utils_mod.get_request_json = _default_request_json
api_utils_mod.get_data_error_result = _get_data_error_result
api_utils_mod.get_json_result = _get_json_result
api_utils_mod.server_error_response = _server_error_response
api_utils_mod.validate_request = _validate_request
api_utils_mod.not_allowed_parameters = _not_allowed_parameters
monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod)
utils_pkg.api_utils = api_utils_mod
module_name = "test_search_routes_unit_module"
module_path = repo_root / "api" / "apps" / "search_app.py"
module_name = "test_search_api_unit_module"
module_path = repo_root / "api" / "apps" / "restful_apis" / "search_api.py"
spec = importlib.util.spec_from_file_location(module_name, module_path)
module = importlib.util.module_from_spec(spec)
module.manager = _DummyManager()
@ -260,7 +261,7 @@ def _load_search_app(monkeypatch):
@pytest.mark.p2
def test_create_route_matrix_unit(monkeypatch):
module = _load_search_app(monkeypatch)
module = _load_search_api(monkeypatch)
_set_request_json(monkeypatch, module, {"name": 1})
res = _run(module.create())
@ -308,40 +309,46 @@ def test_create_route_matrix_unit(monkeypatch):
@pytest.mark.p2
def test_update_and_detail_route_matrix_unit(monkeypatch):
module = _load_search_app(monkeypatch)
module = _load_search_api(monkeypatch)
_set_request_json(monkeypatch, module, {"search_id": "s1", "name": 1, "search_config": {}, "tenant_id": "tenant-1"})
res = _run(module.update())
# update: name not string
_set_request_json(monkeypatch, module, {"name": 1, "search_config": {}})
res = _run(module.update(search_id="s1"))
assert res["code"] == module.RetCode.DATA_ERROR
assert "must be string" in res["message"]
_set_request_json(monkeypatch, module, {"search_id": "s1", "name": " ", "search_config": {}, "tenant_id": "tenant-1"})
res = _run(module.update())
# update: empty name
_set_request_json(monkeypatch, module, {"name": " ", "search_config": {}})
res = _run(module.update(search_id="s1"))
assert res["code"] == module.RetCode.DATA_ERROR
assert "empty" in res["message"].lower()
_set_request_json(monkeypatch, module, {"search_id": "s1", "name": "a" * 256, "search_config": {}, "tenant_id": "tenant-1"})
res = _run(module.update())
# update: name too long
_set_request_json(monkeypatch, module, {"name": "a" * 256, "search_config": {}})
res = _run(module.update(search_id="s1"))
assert res["code"] == module.RetCode.DATA_ERROR
assert "large than" in res["message"]
_set_request_json(monkeypatch, module, {"search_id": "s1", "name": "ok", "search_config": {}, "tenant_id": "tenant-1"})
# update: tenant not found
_set_request_json(monkeypatch, module, {"name": "ok", "search_config": {}})
monkeypatch.setattr(module.TenantService, "get_by_id", lambda _tenant_id: (False, None))
res = _run(module.update())
res = _run(module.update(search_id="s1"))
assert res["code"] == module.RetCode.DATA_ERROR
assert "authorized identity" in res["message"].lower()
# update: no access
monkeypatch.setattr(module.TenantService, "get_by_id", lambda _tenant_id: (True, SimpleNamespace(id=_tenant_id)))
monkeypatch.setattr(module.SearchService, "accessible4deletion", lambda _search_id, _user_id: False)
_set_request_json(monkeypatch, module, {"search_id": "s1", "name": "ok", "search_config": {}, "tenant_id": "tenant-1"})
res = _run(module.update())
_set_request_json(monkeypatch, module, {"name": "ok", "search_config": {}})
res = _run(module.update(search_id="s1"))
assert res["code"] == module.RetCode.AUTHENTICATION_ERROR
assert "authorization" in res["message"].lower()
# update: search not found (query returns [None])
monkeypatch.setattr(module.SearchService, "accessible4deletion", lambda _search_id, _user_id: True)
monkeypatch.setattr(module.SearchService, "query", lambda **_kwargs: [None])
_set_request_json(monkeypatch, module, {"search_id": "s1", "name": "ok", "search_config": {}, "tenant_id": "tenant-1"})
res = _run(module.update())
_set_request_json(monkeypatch, module, {"name": "ok", "search_config": {}})
res = _run(module.update(search_id="s1"))
assert res["code"] == module.RetCode.DATA_ERROR
assert "cannot find search" in res["message"].lower()
@ -354,18 +361,21 @@ def test_update_and_detail_route_matrix_unit(monkeypatch):
return [SimpleNamespace(id="dup")]
return []
# update: duplicate name
monkeypatch.setattr(module.SearchService, "query", _query_duplicate)
_set_request_json(monkeypatch, module, {"search_id": "s1", "name": "new-name", "search_config": {}, "tenant_id": "tenant-1"})
res = _run(module.update())
_set_request_json(monkeypatch, module, {"name": "new-name", "search_config": {}})
res = _run(module.update(search_id="s1"))
assert res["code"] == module.RetCode.DATA_ERROR
assert "duplicated" in res["message"].lower()
# update: search_config not a dict
monkeypatch.setattr(module.SearchService, "query", lambda **_kwargs: [existing])
_set_request_json(monkeypatch, module, {"search_id": "s1", "name": "old-name", "search_config": [], "tenant_id": "tenant-1"})
res = _run(module.update())
_set_request_json(monkeypatch, module, {"name": "old-name", "search_config": []})
res = _run(module.update(search_id="s1"))
assert res["code"] == module.RetCode.DATA_ERROR
assert "json object" in res["message"].lower()
# update: update_by_id fails, verifies config merge and field exclusion
captured = {}
def _update_fail(search_id, req):
@ -374,92 +384,96 @@ def test_update_and_detail_route_matrix_unit(monkeypatch):
return False
monkeypatch.setattr(module.SearchService, "update_by_id", _update_fail)
_set_request_json(monkeypatch, module, {"search_id": "s1", "name": "old-name", "search_config": {"top_k": 3}, "tenant_id": "tenant-1"})
res = _run(module.update())
_set_request_json(monkeypatch, module, {"name": "old-name", "search_config": {"top_k": 3}})
res = _run(module.update(search_id="s1"))
assert res["code"] == module.RetCode.DATA_ERROR
assert "failed to update" in res["message"].lower()
assert captured["search_id"] == "s1"
assert "search_id" not in captured["req"]
assert "tenant_id" not in captured["req"]
assert captured["req"]["search_config"] == {"existing": 1, "top_k": 3}
# update: get_by_id fails after successful update
monkeypatch.setattr(module.SearchService, "update_by_id", lambda _search_id, _req: True)
monkeypatch.setattr(module.SearchService, "get_by_id", lambda _search_id: (False, None))
res = _run(module.update())
res = _run(module.update(search_id="s1"))
assert res["code"] == module.RetCode.DATA_ERROR
assert "failed to fetch" in res["message"].lower()
# update: success
monkeypatch.setattr(
module.SearchService,
"get_by_id",
lambda _search_id: (True, _SearchRecord(search_id=_search_id, name="old-name", search_config={"existing": 1, "top_k": 3})),
)
res = _run(module.update())
res = _run(module.update(search_id="s1"))
assert res["code"] == 0
assert res["data"]["id"] == "s1"
# update: exception
def _raise_query(**_kwargs):
raise RuntimeError("update boom")
monkeypatch.setattr(module.SearchService, "query", _raise_query)
_set_request_json(monkeypatch, module, {"search_id": "s1", "name": "old-name", "search_config": {"top_k": 3}, "tenant_id": "tenant-1"})
res = _run(module.update())
_set_request_json(monkeypatch, module, {"name": "old-name", "search_config": {"top_k": 3}})
res = _run(module.update(search_id="s1"))
assert res["code"] == module.RetCode.EXCEPTION_ERROR
assert "update boom" in res["message"]
_set_request_args(monkeypatch, module, {"search_id": "s1"})
# detail: no permission
monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-a")])
monkeypatch.setattr(module.SearchService, "query", lambda **_kwargs: [])
res = module.detail()
res = module.detail(search_id="s1")
assert res["code"] == module.RetCode.OPERATING_ERROR
assert "permission" in res["message"].lower()
# detail: search not found
monkeypatch.setattr(module.SearchService, "query", lambda **_kwargs: [SimpleNamespace(id="s1")])
monkeypatch.setattr(module.SearchService, "get_detail", lambda _search_id: None)
res = module.detail()
res = module.detail(search_id="s1")
assert res["code"] == module.RetCode.DATA_ERROR
assert "can't find" in res["message"].lower()
# detail: success
monkeypatch.setattr(module.SearchService, "get_detail", lambda _search_id: {"id": _search_id, "name": "detail-name"})
res = module.detail()
res = module.detail(search_id="s1")
assert res["code"] == 0
assert res["data"]["id"] == "s1"
# detail: exception
def _raise_detail(_search_id):
raise RuntimeError("detail boom")
monkeypatch.setattr(module.SearchService, "get_detail", _raise_detail)
res = module.detail()
res = module.detail(search_id="s1")
assert res["code"] == module.RetCode.EXCEPTION_ERROR
assert "detail boom" in res["message"]
@pytest.mark.p2
def test_list_and_rm_route_matrix_unit(monkeypatch):
module = _load_search_app(monkeypatch)
def test_list_and_delete_route_matrix_unit(monkeypatch):
module = _load_search_api(monkeypatch)
# list: no owner_ids, with pagination
_set_request_args(
monkeypatch,
module,
{"keywords": "k", "page": "1", "page_size": "2", "orderby": "create_time", "desc": "false"},
)
_set_request_json(monkeypatch, module, {"owner_ids": []})
monkeypatch.setattr(
module.SearchService,
"get_by_tenant_ids",
lambda _tenants, _uid, _page, _size, _orderby, _desc, _keywords: ([{"id": "a", "tenant_id": "tenant-1"}], 1),
)
res = _run(module.list_search_app())
res = module.list_searches()
assert res["code"] == 0
assert res["data"]["total"] == 1
assert res["data"]["search_apps"][0]["id"] == "a"
# list: with owner_ids filter and pagination
_set_request_args(
monkeypatch,
module,
{"keywords": "k", "page": "1", "page_size": "1", "orderby": "create_time", "desc": "true"},
{"keywords": "k", "page": "1", "page_size": "1", "orderby": "create_time", "desc": "true", "owner_ids": ["tenant-1"]},
)
_set_request_json(monkeypatch, module, {"owner_ids": ["tenant-1"]})
monkeypatch.setattr(
module.SearchService,
"get_by_tenant_ids",
@ -468,42 +482,46 @@ def test_list_and_rm_route_matrix_unit(monkeypatch):
2,
),
)
res = _run(module.list_search_app())
res = module.list_searches()
assert res["code"] == 0
assert res["data"]["total"] == 1
assert len(res["data"]["search_apps"]) == 1
assert res["data"]["search_apps"][0]["tenant_id"] == "tenant-1"
# list: exception
def _raise_list(*_args, **_kwargs):
raise RuntimeError("list boom")
monkeypatch.setattr(module.SearchService, "get_by_tenant_ids", _raise_list)
_set_request_json(monkeypatch, module, {"owner_ids": []})
res = _run(module.list_search_app())
_set_request_args(monkeypatch, module, {})
res = module.list_searches()
assert res["code"] == module.RetCode.EXCEPTION_ERROR
assert "list boom" in res["message"]
_set_request_json(monkeypatch, module, {"search_id": "search-1"})
# delete: no authorization
monkeypatch.setattr(module.SearchService, "accessible4deletion", lambda _search_id, _user_id: False)
res = _run(module.rm())
res = module.delete_search(search_id="search-1")
assert res["code"] == module.RetCode.AUTHENTICATION_ERROR
assert "authorization" in res["message"].lower()
# delete: delete_by_id fails
monkeypatch.setattr(module.SearchService, "accessible4deletion", lambda _search_id, _user_id: True)
monkeypatch.setattr(module.SearchService, "delete_by_id", lambda _search_id: False)
res = _run(module.rm())
res = module.delete_search(search_id="search-1")
assert res["code"] == module.RetCode.DATA_ERROR
assert "failed to delete" in res["message"].lower()
# delete: success
monkeypatch.setattr(module.SearchService, "delete_by_id", lambda _search_id: True)
res = _run(module.rm())
res = module.delete_search(search_id="search-1")
assert res["code"] == 0
assert res["data"] is True
# delete: exception
def _raise_delete(_search_id):
raise RuntimeError("rm boom")
monkeypatch.setattr(module.SearchService, "delete_by_id", _raise_delete)
res = _run(module.rm())
res = module.delete_search(search_id="search-1")
assert res["code"] == module.RetCode.EXCEPTION_ERROR
assert "rm boom" in res["message"]