diff --git a/services/web/server/VERSION b/services/web/server/VERSION index b7c0622b4f46..c52842c467cf 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.74.0 +0.75.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 5b124abce89a..22e2a9f3d1ea 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.74.0 +current_version = 0.75.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index fccc46491e57..f000d49312e0 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: simcore-service-webserver description: Main service with an interface (http-API & websockets) to the web front-end - version: 0.74.0 + version: 0.75.0 servers: - url: '' description: webserver @@ -3670,6 +3670,33 @@ paths: type: boolean default: false title: Include Extras + - name: search + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Search + - name: filters + in: query + required: false + schema: + anyOf: + - type: string + contentMediaType: application/json + contentSchema: {} + - type: 'null' + title: Filters + - name: order_by + in: query + required: false + schema: + type: string + contentMediaType: application/json + contentSchema: {} + default: '{"field":"modified","direction":"desc"}' + title: Order By - name: limit in: query required: false diff --git a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py index 8eb61dffd051..bd77c9123e69 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py +++ b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py @@ -20,6 +20,7 @@ ) from models_library.groups import GroupID from models_library.products import ProductName +from models_library.rest_ordering import OrderBy from models_library.rest_pagination import Page from models_library.rest_pagination_utils import paginate_data from models_library.users import UserID @@ -43,6 +44,7 @@ from .._services_metadata.proxy import ServiceMetadata from ._functions_rest_exceptions import handle_rest_requests_exceptions from ._functions_rest_schemas import ( + FunctionFilters, FunctionGetQueryParams, FunctionGroupPathParams, FunctionPathParams, @@ -172,6 +174,11 @@ async def list_functions(request: web.Request) -> web.Response: FunctionsListQueryParams, request ) + if not query_params.filters: + query_params.filters = FunctionFilters() + + assert query_params.filters # nosec + req_ctx = AuthenticatedRequestContext.model_validate(request) functions, page_meta_info = await _functions_service.list_functions( request.app, @@ -179,6 +186,9 @@ async def list_functions(request: web.Request) -> web.Response: product_name=req_ctx.product_name, pagination_limit=query_params.limit, pagination_offset=query_params.offset, + order_by=OrderBy.model_construct(**query_params.order_by.model_dump()), + search_by_function_title=query_params.filters.search_by_title, + search_by_multi_columns=query_params.search, ) chunk: list[RegisteredFunctionGet] = [] diff --git a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest_schemas.py b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest_schemas.py index 2f8dedb64d92..3fd09ab93fdf 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest_schemas.py +++ b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest_schemas.py @@ -1,7 +1,17 @@ +from typing import Annotated + +from models_library.basic_types import IDStr from models_library.functions import FunctionID from models_library.groups import GroupID +from models_library.rest_base import RequestParameters +from models_library.rest_filters import Filters, FiltersQueryParameters +from models_library.rest_ordering import ( + OrderBy, + OrderDirection, + create_ordering_query_model_class, +) from models_library.rest_pagination import PageQueryParameters -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from ...models import AuthenticatedRequestContext @@ -17,14 +27,56 @@ class FunctionGroupPathParams(FunctionPathParams): group_id: GroupID -class _FunctionQueryParams(BaseModel): +class FunctionQueryParams(BaseModel): include_extras: bool = False -class FunctionGetQueryParams(_FunctionQueryParams): ... +class FunctionGetQueryParams(FunctionQueryParams): ... + + +class FunctionFilters(Filters): + search_by_title: Annotated[ + str | None, + Field( + description="A search query to filter functions by their title. This field performs a case-insensitive partial match against the function title field.", + ), + ] = None + + +FunctionListOrderQueryParams: type[RequestParameters] = ( + create_ordering_query_model_class( + ordering_fields={ + "created_at", + "modified_at", + "name", + }, + default=OrderBy(field=IDStr("modified_at"), direction=OrderDirection.DESC), + ordering_fields_api_to_column_map={ + "created_at": "created", + "modified_at": "modified", + }, + ) +) + + +class FunctionsListExtraQueryParams(RequestParameters): + search: Annotated[ + str | None, + Field( + description="Multi column full text search", + max_length=100, + examples=["My Function"], + ), + ] = None -class FunctionsListQueryParams(PageQueryParameters, _FunctionQueryParams): ... +class FunctionsListQueryParams( + PageQueryParameters, + FunctionListOrderQueryParams, # type: ignore[misc, valid-type] + FiltersQueryParameters[FunctionFilters], + FunctionsListExtraQueryParams, + FunctionQueryParams, +): ... __all__: tuple[str, ...] = ("AuthenticatedRequestContext",) diff --git a/services/web/server/tests/unit/with_dbs/04/functions/test_functions_controller_rest.py b/services/web/server/tests/unit/with_dbs/04/functions/test_functions_controller_rest.py index 8ecef15df64a..0ca5a1158b07 100644 --- a/services/web/server/tests/unit/with_dbs/04/functions/test_functions_controller_rest.py +++ b/services/web/server/tests/unit/with_dbs/04/functions/test_functions_controller_rest.py @@ -11,6 +11,7 @@ import pytest from aiohttp.test_utils import TestClient +from common_library.json_serialization import json_dumps from models_library.api_schemas_webserver.functions import ( FunctionClass, JSONFunctionInputSchema, @@ -27,6 +28,37 @@ pytest_simcore_core_services_selection = ["rabbit"] +async def _list_functions_and_validate( + client: TestClient, + expected_status: HTTPStatus, + expected_count: int | None = None, + params: dict[str, Any] | None = None, + expected_uid_in_results: str | None = None, + expected_uid_at_index: tuple[str, int] | None = None, +) -> list[RegisteredFunctionGet] | None: + """Helper function to list functions and validate the response.""" + url = client.app.router["list_functions"].url_for() + response = await client.get(url, params=params or {}) + data, error = await assert_status(response, expected_status) + + if error: + return None + + retrieved_functions = TypeAdapter(list[RegisteredFunctionGet]).validate_python(data) + + if expected_count is not None: + assert len(retrieved_functions) == expected_count + + if expected_uid_in_results is not None: + assert expected_uid_in_results in [f"{f.uid}" for f in retrieved_functions] + + if expected_uid_at_index is not None: + expected_uid, index = expected_uid_at_index + assert f"{retrieved_functions[index].uid}" == expected_uid + + return retrieved_functions + + @pytest.fixture(params=[FunctionClass.PROJECT, FunctionClass.SOLVER]) def mocked_function(request) -> dict[str, Any]: function_dict = { @@ -109,6 +141,12 @@ async def test_function_workflow( assert returned_function.uid is not None returned_function_uid = returned_function.uid + # Register a new function (duplicate) + url = client.app.router["register_function"].url_for() + mocked_function.update(title=mocked_function["title"] + " (duplicate)") + response = await client.post(url, json=mocked_function) + await assert_status(response, expected_status_code=expected_register) + # Get the registered function url = client.app.router["get_function"].url_for( function_id=f"{returned_function_uid}" @@ -119,6 +157,52 @@ async def test_function_workflow( retrieved_function = TypeAdapter(RegisteredFunctionGet).validate_python(data) assert retrieved_function.uid == returned_function.uid + # List existing functions (default) + await _list_functions_and_validate( + client, + expected_list, + expected_count=2, + expected_uid_in_results=f"{returned_function_uid}", + expected_uid_at_index=( + f"{returned_function_uid}", + 1, + ), # ordered by modified_at by default + ) + + # List existing functions (ordered by created_at ascending) + await _list_functions_and_validate( + client, + expected_list, + expected_count=2, + params={"order_by": json_dumps({"field": "created_at", "direction": "asc"})}, + expected_uid_in_results=f"{returned_function_uid}", + expected_uid_at_index=(f"{returned_function_uid}", 0), + ) + + # List existing functions (searching for not existing) + await _list_functions_and_validate( + client, + expected_list, + expected_count=0, + params={"search": "you_can_not_find_me_because_I_do_not_exist"}, + ) + + # List existing functions (searching for duplicate) + await _list_functions_and_validate( + client, + expected_list, + expected_count=1, + params={"search": "duplicate"}, + ) + + # List existing functions (searching by title) + await _list_functions_and_validate( + client, + expected_list, + expected_count=1, + params={"filters": json_dumps({"search_by_title": "duplicate"})}, + ) + # Set group permissions for other user new_group_id = other_logged_user["primary_gid"] new_group_access_rights = {"read": True, "write": True, "execute": False} @@ -156,17 +240,6 @@ async def test_function_workflow( new_group_id: new_group_access_rights } - # List existing functions - url = client.app.router["list_functions"].url_for() - response = await client.get(url) - data, error = await assert_status(response, expected_list) - if not error: - retrieved_functions = TypeAdapter(list[RegisteredFunctionGet]).validate_python( - data - ) - assert len(retrieved_functions) == 1 - assert retrieved_functions[0].uid == returned_function_uid - # Update existing function new_title = "Test Function (edited)" new_description = "A test function (edited)"