From d19ca71b43a9aff216ef2ca5529fe004ab5eafbe Mon Sep 17 00:00:00 2001 From: Yongteng Lei Date: Thu, 26 Mar 2026 01:07:41 +0800 Subject: [PATCH] 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 Co-authored-by: Yingfeng --- .../search_api.py} | 153 ++++----- docs/references/http_api_reference.md | 302 ++++++++++++++++++ test/playwright/helpers/_next_apps_helpers.py | 2 +- test/testcases/test_web_api/common.py | 22 +- .../test_search_app/test_search_crud.py | 45 +-- .../test_search_routes_unit.py | 128 ++++---- web/src/pages/next-searches/hooks.ts | 28 +- web/src/services/search-service.ts | 17 +- web/src/utils/api.ts | 13 +- web/src/utils/llm-util.ts | 2 +- 10 files changed, 494 insertions(+), 218 deletions(-) rename api/apps/{search_app.py => restful_apis/search_api.py} (73%) diff --git a/api/apps/search_app.py b/api/apps/restful_apis/search_api.py similarity index 73% rename from api/apps/search_app.py rename to api/apps/restful_apis/search_api.py index d82c3b27d..82a357f30 100644 --- a/api/apps/search_app.py +++ b/api/apps/restful_apis/search_api.py @@ -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/", 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/", 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/", 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) diff --git a/docs/references/http_api_reference.md b/docs/references/http_api_reference.md index 886627369..1573a3cda 100644 --- a/docs/references/http_api_reference.md +++ b/docs/references/http_api_reference.md @@ -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 '` +- Body: + +```json +{ + "name": "my_search_app", + "description": "optional description" +} +``` + +##### Request example + +```bash +curl --request POST \ + --url 'http://{address}/api/v1/searches' \ + --header 'Authorization: Bearer ' \ + --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 '` + +##### Request example + +```bash +curl --request GET \ + --url 'http://{address}/api/v1/searches?page=1&page_size=20' \ + --header 'Authorization: Bearer ' +``` + +##### 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 '` + +##### Request example + +```bash +curl --request GET \ + --url 'http://{address}/api/v1/searches/b330ec2e91ec11efbc510242ac120006' \ + --header 'Authorization: Bearer ' +``` + +##### 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 '` +- 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 ' \ + --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 '` + +##### Request example + +```bash +curl --request DELETE \ + --url 'http://{address}/api/v1/searches/b330ec2e91ec11efbc510242ac120006' \ + --header 'Authorization: Bearer ' +``` + +##### 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." +} +``` diff --git a/test/playwright/helpers/_next_apps_helpers.py b/test/playwright/helpers/_next_apps_helpers.py index 64a0aa312..f3607feb2 100644 --- a/test/playwright/helpers/_next_apps_helpers.py +++ b/test/playwright/helpers/_next_apps_helpers.py @@ -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 = "" diff --git a/test/testcases/test_web_api/common.py b/test/testcases/test_web_api/common.py index 9b091539f..12455242d 100644 --- a/test/testcases/test_web_api/common.py +++ b/test/testcases/test_web_api/common.py @@ -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() diff --git a/test/testcases/test_web_api/test_search_app/test_search_crud.py b/test/testcases/test_web_api/test_search_app/test_search_crud.py index 24715cb38..6cefde57b 100644 --- a/test/testcases/test_web_api/test_search_app/test_search_crud.py +++ b/test/testcases/test_web_api/test_search_app/test_search_crud.py @@ -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 diff --git a/test/testcases/test_web_api/test_search_app/test_search_routes_unit.py b/test/testcases/test_web_api/test_search_app/test_search_routes_unit.py index e7c9adc89..c755313b7 100644 --- a/test/testcases/test_web_api/test_search_app/test_search_routes_unit.py +++ b/test/testcases/test_web_api/test_search_app/test_search_routes_unit.py @@ -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"] diff --git a/web/src/pages/next-searches/hooks.ts b/web/src/pages/next-searches/hooks.ts index 19cf0a89b..a79481045 100644 --- a/web/src/pages/next-searches/hooks.ts +++ b/web/src/pages/next-searches/hooks.ts @@ -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({ 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'); } diff --git a/web/src/services/search-service.ts b/web/src/services/search-service.ts index 2e0c6e638..af8d691bd 100644 --- a/web/src/services/search-service.ts +++ b/web/src/services/search-service.ts @@ -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(methods, request); -export const searchServiceNext = - registerNextServer(methods); + +const searchService = registerNextServer(methods); +export const searchServiceNext = searchService; export default searchService; diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts index 21c08c286..5df45ad47 100644 --- a/web/src/utils/api.ts +++ b/web/src/utils/api.ts @@ -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`, diff --git a/web/src/utils/llm-util.ts b/web/src/utils/llm-util.ts index 1a081d205..b267642ed 100644 --- a/web/src/utils/llm-util.ts +++ b/web/src/utils/llm-util.ts @@ -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',