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

@ -24,10 +24,10 @@ from api.db.services.search_service import SearchService
from api.db.services.user_service import TenantService, UserTenantService
from common.misc_utils import get_uuid
from common.constants import RetCode, StatusEnum
from api.utils.api_utils import get_data_error_result, get_json_result, not_allowed_parameters, get_request_json, server_error_response, validate_request
from api.utils.api_utils import get_data_error_result, get_json_result, get_request_json, server_error_response, validate_request
@manager.route("/create", methods=["post"]) # noqa: F821
@manager.route("/searches", methods=["POST"]) # noqa: F821
@login_required
@validate_request("name")
async def create():
@ -61,67 +61,34 @@ async def create():
return server_error_response(e)
@manager.route("/update", methods=["post"]) # noqa: F821
@manager.route("/searches", methods=["GET"]) # noqa: F821
@login_required
@validate_request("search_id", "name", "search_config", "tenant_id")
@not_allowed_parameters("id", "created_by", "create_time", "update_time", "create_date", "update_date", "created_by")
async def update():
req = await get_request_json()
if not isinstance(req["name"], str):
return get_data_error_result(message="Search name must be string.")
if req["name"].strip() == "":
return get_data_error_result(message="Search name can't be empty.")
if len(req["name"].encode("utf-8")) > DATASET_NAME_LIMIT:
return get_data_error_result(message=f"Search name length is {len(req['name'])} which is large than {DATASET_NAME_LIMIT}")
req["name"] = req["name"].strip()
tenant_id = req["tenant_id"]
e, _ = TenantService.get_by_id(tenant_id)
if not e:
return get_data_error_result(message="Authorized identity.")
search_id = req["search_id"]
if not SearchService.accessible4deletion(search_id, current_user.id):
return get_json_result(data=False, message="No authorization.", code=RetCode.AUTHENTICATION_ERROR)
def list_searches():
keywords = request.args.get("keywords", "")
page_number = int(request.args.get("page", 0))
items_per_page = int(request.args.get("page_size", 0))
orderby = request.args.get("orderby", "create_time")
desc = request.args.get("desc", "true").lower() != "false"
owner_ids = request.args.getlist("owner_ids")
try:
search_app = SearchService.query(tenant_id=tenant_id, id=search_id)[0]
if not search_app:
return get_json_result(data=False, message=f"Cannot find search {search_id}", code=RetCode.DATA_ERROR)
if req["name"].lower() != search_app.name.lower() and len(SearchService.query(name=req["name"], tenant_id=tenant_id, status=StatusEnum.VALID.value)) >= 1:
return get_data_error_result(message="Duplicated search name.")
if "search_config" in req:
current_config = search_app.search_config or {}
new_config = req["search_config"]
if not isinstance(new_config, dict):
return get_data_error_result(message="search_config must be a JSON object")
updated_config = {**current_config, **new_config}
req["search_config"] = updated_config
req.pop("search_id", None)
req.pop("tenant_id", None)
updated = SearchService.update_by_id(search_id, req)
if not updated:
return get_data_error_result(message="Failed to update search")
e, updated_search = SearchService.get_by_id(search_id)
if not e:
return get_data_error_result(message="Failed to fetch updated search")
return get_json_result(data=updated_search.to_dict())
if not owner_ids:
tenants = []
search_apps, total = SearchService.get_by_tenant_ids(tenants, current_user.id, page_number, items_per_page, orderby, desc, keywords)
else:
search_apps, total = SearchService.get_by_tenant_ids(owner_ids, current_user.id, 0, 0, orderby, desc, keywords)
search_apps = [s for s in search_apps if s["tenant_id"] in owner_ids]
total = len(search_apps)
if page_number and items_per_page:
search_apps = search_apps[(page_number - 1) * items_per_page: page_number * items_per_page]
return get_json_result(data={"search_apps": search_apps, "total": total})
except Exception as e:
return server_error_response(e)
@manager.route("/detail", methods=["GET"]) # noqa: F821
@manager.route("/searches/<search_id>", methods=["GET"]) # noqa: F821
@login_required
def detail():
search_id = request.args["search_id"]
def detail(search_id):
try:
tenants = UserTenantService.query(user_id=current_user.id)
for tenant in tenants:
@ -138,44 +105,60 @@ def detail():
return server_error_response(e)
@manager.route("/list", methods=["POST"]) # noqa: F821
@manager.route("/searches/<search_id>", methods=["PUT"]) # noqa: F821
@login_required
async def list_search_app():
keywords = request.args.get("keywords", "")
page_number = int(request.args.get("page", 0))
items_per_page = int(request.args.get("page_size", 0))
orderby = request.args.get("orderby", "create_time")
if request.args.get("desc", "true").lower() == "false":
desc = False
else:
desc = True
@validate_request("name", "search_config")
async def update(search_id):
req = await get_request_json()
owner_ids = req.get("owner_ids", [])
if not isinstance(req["name"], str):
return get_data_error_result(message="Search name must be string.")
if req["name"].strip() == "":
return get_data_error_result(message="Search name can't be empty.")
if len(req["name"].encode("utf-8")) > DATASET_NAME_LIMIT:
return get_data_error_result(message=f"Search name length is {len(req['name'])} which is large than {DATASET_NAME_LIMIT}")
req["name"] = req["name"].strip()
e, _ = TenantService.get_by_id(current_user.id)
if not e:
return get_data_error_result(message="Authorized identity.")
if not SearchService.accessible4deletion(search_id, current_user.id):
return get_json_result(data=False, message="No authorization.", code=RetCode.AUTHENTICATION_ERROR)
try:
if not owner_ids:
# tenants = TenantService.get_joined_tenants_by_user_id(current_user.id)
# tenants = [m["tenant_id"] for m in tenants]
tenants = []
search_apps, total = SearchService.get_by_tenant_ids(tenants, current_user.id, page_number, items_per_page, orderby, desc, keywords)
else:
tenants = owner_ids
search_apps, total = SearchService.get_by_tenant_ids(tenants, current_user.id, 0, 0, orderby, desc, keywords)
search_apps = [search_app for search_app in search_apps if search_app["tenant_id"] in tenants]
total = len(search_apps)
if page_number and items_per_page:
search_apps = search_apps[(page_number - 1) * items_per_page : page_number * items_per_page]
return get_json_result(data={"search_apps": search_apps, "total": total})
search_app = SearchService.query(tenant_id=current_user.id, id=search_id)[0]
if not search_app:
return get_json_result(data=False, message=f"Cannot find search {search_id}", code=RetCode.DATA_ERROR)
if req["name"].lower() != search_app.name.lower() and len(SearchService.query(name=req["name"], tenant_id=current_user.id, status=StatusEnum.VALID.value)) >= 1:
return get_data_error_result(message="Duplicated search name.")
current_config = search_app.search_config or {}
new_config = req["search_config"]
if not isinstance(new_config, dict):
return get_data_error_result(message="search_config must be a JSON object")
req["search_config"] = {**current_config, **new_config}
for field in ("search_id", "tenant_id", "created_by", "update_time", "id"):
req.pop(field, None)
updated = SearchService.update_by_id(search_id, req)
if not updated:
return get_data_error_result(message="Failed to update search")
e, updated_search = SearchService.get_by_id(search_id)
if not e:
return get_data_error_result(message="Failed to fetch updated search")
return get_json_result(data=updated_search.to_dict())
except Exception as e:
return server_error_response(e)
@manager.route("/rm", methods=["post"]) # noqa: F821
@manager.route("/searches/<search_id>", methods=["DELETE"]) # noqa: F821
@login_required
@validate_request("search_id")
async def rm():
req = await get_request_json()
search_id = req["search_id"]
def delete_search(search_id):
if not SearchService.accessible4deletion(search_id, current_user.id):
return get_json_result(data=False, message="No authorization.", code=RetCode.AUTHENTICATION_ERROR)

View File

@ -7034,3 +7034,305 @@ or
"message": "Can't find this dataset!"
}
```
---
## SEARCH APP MANAGEMENT
### Create search app
**POST** `/api/v1/searches`
Creates a search app.
#### Request
- Method: POST
- URL: `/api/v1/searches`
- Headers:
- `'Content-Type: application/json'`
- `'Authorization: Bearer <YOUR_API_KEY>'`
- Body:
```json
{
"name": "my_search_app",
"description": "optional description"
}
```
##### Request example
```bash
curl --request POST \
--url 'http://{address}/api/v1/searches' \
--header 'Authorization: Bearer <YOUR_API_KEY>' \
--header 'Content-Type: application/json' \
--data '{
"name": "my_search_app",
"description": "My first search app"
}'
```
##### Request parameters
- `"name"`: (*Body parameter*), `string`, *Required*
The name of the search app. Must be unique and no longer than 255 characters.
- `"description"`: (*Body parameter*), `string`
A brief description of the search app.
#### Response
Success:
```json
{
"code": 0,
"data": {
"search_id": "b330ec2e91ec11efbc510242ac120006"
}
}
```
Failure:
```json
{
"code": 102,
"message": "Search name can't be empty."
}
```
---
### List search apps
**GET** `/api/v1/searches?keywords={keywords}&page={page}&page_size={page_size}&orderby={orderby}&desc={desc}&owner_ids={owner_ids}`
Lists search apps for the current user.
#### Request
- Method: GET
- URL: `/api/v1/searches`
- Headers:
- `'Authorization: Bearer <YOUR_API_KEY>'`
##### Request example
```bash
curl --request GET \
--url 'http://{address}/api/v1/searches?page=1&page_size=20' \
--header 'Authorization: Bearer <YOUR_API_KEY>'
```
##### Request parameters
- `keywords`: (*Filter parameter*), `string`
Search keyword to filter search apps by name.
- `page`: (*Filter parameter*), `integer`
Specifies the page number. Defaults to `0` (no pagination).
- `page_size`: (*Filter parameter*), `integer`
The number of items per page. Defaults to `0` (no pagination).
- `orderby`: (*Filter parameter*), `string`
The field to sort by. Defaults to `create_time`.
- `desc`: (*Filter parameter*), `boolean`
Whether to sort in descending order. Defaults to `true`.
- `owner_ids`: (*Filter parameter*), `string` (repeatable)
Filter by owner tenant IDs. Can be specified multiple times: `?owner_ids=id1&owner_ids=id2`.
#### Response
Success:
```json
{
"code": 0,
"data": {
"total": 2,
"search_apps": [
{
"id": "b330ec2e91ec11efbc510242ac120006",
"name": "my_search_app",
"description": "My first search app",
"tenant_id": "7c8983badede11f083f184ba59bc53c7",
"create_time": 1729763127646
}
]
}
}
```
---
### Get search app
**GET** `/api/v1/searches/{search_id}`
Gets the details of a search app.
#### Request
- Method: GET
- URL: `/api/v1/searches/{search_id}`
- Headers:
- `'Authorization: Bearer <YOUR_API_KEY>'`
##### Request example
```bash
curl --request GET \
--url 'http://{address}/api/v1/searches/b330ec2e91ec11efbc510242ac120006' \
--header 'Authorization: Bearer <YOUR_API_KEY>'
```
##### Request parameters
- `search_id`: (*Path parameter*), `string`, *Required*
The ID of the search app to retrieve.
#### Response
Success:
```json
{
"code": 0,
"data": {
"id": "b330ec2e91ec11efbc510242ac120006",
"name": "my_search_app",
"description": "My first search app",
"tenant_id": "7c8983badede11f083f184ba59bc53c7",
"search_config": {},
"create_time": 1729763127646
}
}
```
Failure:
```json
{
"code": 102,
"message": "Can't find this Search App!"
}
```
---
### Update search app
**PUT** `/api/v1/searches/{search_id}`
Updates a search app.
#### Request
- Method: PUT
- URL: `/api/v1/searches/{search_id}`
- Headers:
- `'Content-Type: application/json'`
- `'Authorization: Bearer <YOUR_API_KEY>'`
- Body:
```json
{
"name": "updated_name",
"search_config": {"top_k": 5}
}
```
##### Request example
```bash
curl --request PUT \
--url 'http://{address}/api/v1/searches/b330ec2e91ec11efbc510242ac120006' \
--header 'Authorization: Bearer <YOUR_API_KEY>' \
--header 'Content-Type: application/json' \
--data '{
"name": "updated_name",
"search_config": {"top_k": 5}
}'
```
##### Request parameters
- `search_id`: (*Path parameter*), `string`, *Required*
The ID of the search app to update.
- `"name"`: (*Body parameter*), `string`, *Required*
The new name of the search app.
- `"search_config"`: (*Body parameter*), `object`, *Required*
Configuration fields to update. Merged with the existing config.
#### Response
Success:
```json
{
"code": 0,
"data": {
"id": "b330ec2e91ec11efbc510242ac120006",
"name": "updated_name",
"search_config": {"top_k": 5},
"create_time": 1729763127646
}
}
```
Failure:
```json
{
"code": 109,
"message": "No authorization."
}
```
---
### Delete search app
**DELETE** `/api/v1/searches/{search_id}`
Deletes a search app.
#### Request
- Method: DELETE
- URL: `/api/v1/searches/{search_id}`
- Headers:
- `'Authorization: Bearer <YOUR_API_KEY>'`
##### Request example
```bash
curl --request DELETE \
--url 'http://{address}/api/v1/searches/b330ec2e91ec11efbc510242ac120006' \
--header 'Authorization: Bearer <YOUR_API_KEY>'
```
##### Request parameters
- `search_id`: (*Path parameter*), `string`, *Required*
The ID of the search app to delete.
#### Response
Success:
```json
{
"code": 0,
"data": true
}
```
Failure:
```json
{
"code": 109,
"message": "No authorization."
}
```

View File

@ -379,7 +379,7 @@ def _select_first_dataset_and_save(
return isinstance(kb_ids, list) and len(kb_ids) > 0
response_url_pattern = (
"/dialog/set" if save_testid == "chat-settings-save" else "/search/update"
"/dialog/set" if save_testid == "chat-settings-save" else "/api/v1/searches/"
)
last_payload = {}
last_combobox_text = ""

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"]

View File

@ -4,7 +4,7 @@ import message from '@/components/ui/message';
import { useSetModalState } from '@/hooks/common-hooks';
import { useHandleSearchChange } from '@/hooks/logic-hooks';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import searchService, { searchServiceNext } from '@/services/search-service';
import searchService from '@/services/search-service';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useDebounce } from 'ahooks';
import { useCallback, useState } from 'react';
@ -103,14 +103,13 @@ export const useFetchSearchList = () => {
},
],
queryFn: async () => {
const { data: response } = await searchServiceNext.getSearchList(
const { data: response } = await searchService.getSearchList(
{
params: {
keywords: debouncedSearchString,
page_size: pagination.pageSize,
page: pagination.current,
},
data: {},
},
true,
);
@ -203,24 +202,21 @@ export const useFetchSearchDetail = (tenantId?: string) => {
const [searchParams] = useSearchParams();
const shared_id = searchParams.get('shared_id');
const searchId = id || shared_id;
let param: { search_id: string | null; tenant_id?: string } = {
search_id: searchId,
};
if (shared_id) {
param = {
search_id: searchId,
tenant_id: tenantId,
};
}
const fetchSearchDetailFunc = shared_id
? searchService.getSearchDetailShare
: searchService.getSearchDetail;
const { data, isLoading, isError } = useQuery<SearchDetailResponse, Error>({
queryKey: ['searchDetail', searchId],
enabled: !shared_id || !!tenantId,
queryFn: async () => {
const { data: response } = await fetchSearchDetailFunc(param);
let res;
if (shared_id) {
res = await searchService.getSearchDetailShare(
{ params: { search_id: searchId, tenant_id: tenantId } },
true,
);
} else {
res = await searchService.getSearchDetail({ search_id: searchId });
}
const response = res.data;
if (response.code !== 0) {
throw new Error(response.message || 'Failed to fetch search detail');
}

View File

@ -1,6 +1,5 @@
import api from '@/utils/api';
import registerServer, { registerNextServer } from '@/utils/register-server';
import request from '@/utils/request';
import { registerNextServer } from '@/utils/register-server';
const {
createSearch,
@ -13,6 +12,7 @@ const {
getRelatedQuestionsShare,
getSearchDetailShare,
} = api;
const methods = {
createSearch: {
url: createSearch,
@ -20,16 +20,16 @@ const methods = {
},
getSearchList: {
url: getSearchList,
method: 'post',
method: 'get',
},
deleteSearch: { url: deleteSearch, method: 'post' },
deleteSearch: { url: deleteSearch, method: 'delete' },
getSearchDetail: {
url: getSearchDetail,
method: 'get',
},
updateSearchSetting: {
url: updateSearchSetting,
method: 'post',
method: 'put',
},
askShare: {
url: askShare,
@ -43,14 +43,13 @@ const methods = {
url: getRelatedQuestionsShare,
method: 'post',
},
getSearchDetailShare: {
url: getSearchDetailShare,
method: 'get',
},
} as const;
const searchService = registerServer<keyof typeof methods>(methods, request);
export const searchServiceNext =
registerNextServer<keyof typeof methods>(methods);
const searchService = registerNextServer<keyof typeof methods>(methods);
export const searchServiceNext = searchService;
export default searchService;

View File

@ -237,12 +237,15 @@ export default {
testMcpServer: `${api_host}/mcp_server/test_mcp`,
// next-search
createSearch: `${api_host}/search/create`,
getSearchList: `${api_host}/search/list`,
deleteSearch: `${api_host}/search/rm`,
getSearchDetail: `${api_host}/search/detail`,
createSearch: `${ExternalApi}${api_host}/searches`,
getSearchList: `${ExternalApi}${api_host}/searches`,
deleteSearch: (params: { search_id: string }) =>
`${ExternalApi}${api_host}/searches/${params.search_id}`,
getSearchDetail: (params: { search_id: string }) =>
`${ExternalApi}${api_host}/searches/${params.search_id}`,
getSearchDetailShare: `${ExternalApi}${api_host}/searchbots/detail`,
updateSearchSetting: `${api_host}/search/update`,
updateSearchSetting: (params: { search_id: string }) =>
`${ExternalApi}${api_host}/searches/${params.search_id}`,
askShare: `${ExternalApi}${api_host}/searchbots/ask`,
mindmapShare: `${ExternalApi}${api_host}/searchbots/mindmap`,
getRelatedQuestionsShare: `${ExternalApi}${api_host}/searchbots/related_questions`,

View File

@ -82,7 +82,7 @@ const API_WHITELIST = [
'/v1/dialog/set',
'/v1/canvas/set',
'/v1/canvas/setting',
'/v1/search/update',
'/api/v1/searches/',
'/api/v1/memories',
'/v1/kb/create',
'/v1/kb/update',