Skip to content

Commit d296fbe

Browse files
✨ Add ordering and filtering when listing Functions (#8229)
1 parent 0c24036 commit d296fbe

File tree

6 files changed

+180
-18
lines changed

6 files changed

+180
-18
lines changed

services/web/server/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.74.0
1+
0.75.0

services/web/server/setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 0.74.0
2+
current_version = 0.75.0
33
commit = True
44
message = services/webserver api version: {current_version} → {new_version}
55
tag = False

services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ openapi: 3.1.0
22
info:
33
title: simcore-service-webserver
44
description: Main service with an interface (http-API & websockets) to the web front-end
5-
version: 0.74.0
5+
version: 0.75.0
66
servers:
77
- url: ''
88
description: webserver
@@ -3670,6 +3670,33 @@ paths:
36703670
type: boolean
36713671
default: false
36723672
title: Include Extras
3673+
- name: search
3674+
in: query
3675+
required: false
3676+
schema:
3677+
anyOf:
3678+
- type: string
3679+
- type: 'null'
3680+
title: Search
3681+
- name: filters
3682+
in: query
3683+
required: false
3684+
schema:
3685+
anyOf:
3686+
- type: string
3687+
contentMediaType: application/json
3688+
contentSchema: {}
3689+
- type: 'null'
3690+
title: Filters
3691+
- name: order_by
3692+
in: query
3693+
required: false
3694+
schema:
3695+
type: string
3696+
contentMediaType: application/json
3697+
contentSchema: {}
3698+
default: '{"field":"modified","direction":"desc"}'
3699+
title: Order By
36733700
- name: limit
36743701
in: query
36753702
required: false

services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from models_library.groups import GroupID
2222
from models_library.products import ProductName
23+
from models_library.rest_ordering import OrderBy
2324
from models_library.rest_pagination import Page
2425
from models_library.rest_pagination_utils import paginate_data
2526
from models_library.users import UserID
@@ -43,6 +44,7 @@
4344
from .._services_metadata.proxy import ServiceMetadata
4445
from ._functions_rest_exceptions import handle_rest_requests_exceptions
4546
from ._functions_rest_schemas import (
47+
FunctionFilters,
4648
FunctionGetQueryParams,
4749
FunctionGroupPathParams,
4850
FunctionPathParams,
@@ -172,13 +174,21 @@ async def list_functions(request: web.Request) -> web.Response:
172174
FunctionsListQueryParams, request
173175
)
174176

177+
if not query_params.filters:
178+
query_params.filters = FunctionFilters()
179+
180+
assert query_params.filters # nosec
181+
175182
req_ctx = AuthenticatedRequestContext.model_validate(request)
176183
functions, page_meta_info = await _functions_service.list_functions(
177184
request.app,
178185
user_id=req_ctx.user_id,
179186
product_name=req_ctx.product_name,
180187
pagination_limit=query_params.limit,
181188
pagination_offset=query_params.offset,
189+
order_by=OrderBy.model_construct(**query_params.order_by.model_dump()),
190+
search_by_function_title=query_params.filters.search_by_title,
191+
search_by_multi_columns=query_params.search,
182192
)
183193

184194
chunk: list[RegisteredFunctionGet] = []

services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest_schemas.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1+
from typing import Annotated
2+
3+
from models_library.basic_types import IDStr
14
from models_library.functions import FunctionID
25
from models_library.groups import GroupID
6+
from models_library.rest_base import RequestParameters
7+
from models_library.rest_filters import Filters, FiltersQueryParameters
8+
from models_library.rest_ordering import (
9+
OrderBy,
10+
OrderDirection,
11+
create_ordering_query_model_class,
12+
)
313
from models_library.rest_pagination import PageQueryParameters
4-
from pydantic import BaseModel, ConfigDict
14+
from pydantic import BaseModel, ConfigDict, Field
515

616
from ...models import AuthenticatedRequestContext
717

@@ -17,14 +27,56 @@ class FunctionGroupPathParams(FunctionPathParams):
1727
group_id: GroupID
1828

1929

20-
class _FunctionQueryParams(BaseModel):
30+
class FunctionQueryParams(BaseModel):
2131
include_extras: bool = False
2232

2333

24-
class FunctionGetQueryParams(_FunctionQueryParams): ...
34+
class FunctionGetQueryParams(FunctionQueryParams): ...
35+
36+
37+
class FunctionFilters(Filters):
38+
search_by_title: Annotated[
39+
str | None,
40+
Field(
41+
description="A search query to filter functions by their title. This field performs a case-insensitive partial match against the function title field.",
42+
),
43+
] = None
44+
45+
46+
FunctionListOrderQueryParams: type[RequestParameters] = (
47+
create_ordering_query_model_class(
48+
ordering_fields={
49+
"created_at",
50+
"modified_at",
51+
"name",
52+
},
53+
default=OrderBy(field=IDStr("modified_at"), direction=OrderDirection.DESC),
54+
ordering_fields_api_to_column_map={
55+
"created_at": "created",
56+
"modified_at": "modified",
57+
},
58+
)
59+
)
60+
61+
62+
class FunctionsListExtraQueryParams(RequestParameters):
63+
search: Annotated[
64+
str | None,
65+
Field(
66+
description="Multi column full text search",
67+
max_length=100,
68+
examples=["My Function"],
69+
),
70+
] = None
2571

2672

27-
class FunctionsListQueryParams(PageQueryParameters, _FunctionQueryParams): ...
73+
class FunctionsListQueryParams(
74+
PageQueryParameters,
75+
FunctionListOrderQueryParams, # type: ignore[misc, valid-type]
76+
FiltersQueryParameters[FunctionFilters],
77+
FunctionsListExtraQueryParams,
78+
FunctionQueryParams,
79+
): ...
2880

2981

3082
__all__: tuple[str, ...] = ("AuthenticatedRequestContext",)

services/web/server/tests/unit/with_dbs/04/functions/test_functions_controller_rest.py

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import pytest
1313
from aiohttp.test_utils import TestClient
14+
from common_library.json_serialization import json_dumps
1415
from models_library.api_schemas_webserver.functions import (
1516
FunctionClass,
1617
JSONFunctionInputSchema,
@@ -27,6 +28,37 @@
2728
pytest_simcore_core_services_selection = ["rabbit"]
2829

2930

31+
async def _list_functions_and_validate(
32+
client: TestClient,
33+
expected_status: HTTPStatus,
34+
expected_count: int | None = None,
35+
params: dict[str, Any] | None = None,
36+
expected_uid_in_results: str | None = None,
37+
expected_uid_at_index: tuple[str, int] | None = None,
38+
) -> list[RegisteredFunctionGet] | None:
39+
"""Helper function to list functions and validate the response."""
40+
url = client.app.router["list_functions"].url_for()
41+
response = await client.get(url, params=params or {})
42+
data, error = await assert_status(response, expected_status)
43+
44+
if error:
45+
return None
46+
47+
retrieved_functions = TypeAdapter(list[RegisteredFunctionGet]).validate_python(data)
48+
49+
if expected_count is not None:
50+
assert len(retrieved_functions) == expected_count
51+
52+
if expected_uid_in_results is not None:
53+
assert expected_uid_in_results in [f"{f.uid}" for f in retrieved_functions]
54+
55+
if expected_uid_at_index is not None:
56+
expected_uid, index = expected_uid_at_index
57+
assert f"{retrieved_functions[index].uid}" == expected_uid
58+
59+
return retrieved_functions
60+
61+
3062
@pytest.fixture(params=[FunctionClass.PROJECT, FunctionClass.SOLVER])
3163
def mocked_function(request) -> dict[str, Any]:
3264
function_dict = {
@@ -109,6 +141,12 @@ async def test_function_workflow(
109141
assert returned_function.uid is not None
110142
returned_function_uid = returned_function.uid
111143

144+
# Register a new function (duplicate)
145+
url = client.app.router["register_function"].url_for()
146+
mocked_function.update(title=mocked_function["title"] + " (duplicate)")
147+
response = await client.post(url, json=mocked_function)
148+
await assert_status(response, expected_status_code=expected_register)
149+
112150
# Get the registered function
113151
url = client.app.router["get_function"].url_for(
114152
function_id=f"{returned_function_uid}"
@@ -119,6 +157,52 @@ async def test_function_workflow(
119157
retrieved_function = TypeAdapter(RegisteredFunctionGet).validate_python(data)
120158
assert retrieved_function.uid == returned_function.uid
121159

160+
# List existing functions (default)
161+
await _list_functions_and_validate(
162+
client,
163+
expected_list,
164+
expected_count=2,
165+
expected_uid_in_results=f"{returned_function_uid}",
166+
expected_uid_at_index=(
167+
f"{returned_function_uid}",
168+
1,
169+
), # ordered by modified_at by default
170+
)
171+
172+
# List existing functions (ordered by created_at ascending)
173+
await _list_functions_and_validate(
174+
client,
175+
expected_list,
176+
expected_count=2,
177+
params={"order_by": json_dumps({"field": "created_at", "direction": "asc"})},
178+
expected_uid_in_results=f"{returned_function_uid}",
179+
expected_uid_at_index=(f"{returned_function_uid}", 0),
180+
)
181+
182+
# List existing functions (searching for not existing)
183+
await _list_functions_and_validate(
184+
client,
185+
expected_list,
186+
expected_count=0,
187+
params={"search": "you_can_not_find_me_because_I_do_not_exist"},
188+
)
189+
190+
# List existing functions (searching for duplicate)
191+
await _list_functions_and_validate(
192+
client,
193+
expected_list,
194+
expected_count=1,
195+
params={"search": "duplicate"},
196+
)
197+
198+
# List existing functions (searching by title)
199+
await _list_functions_and_validate(
200+
client,
201+
expected_list,
202+
expected_count=1,
203+
params={"filters": json_dumps({"search_by_title": "duplicate"})},
204+
)
205+
122206
# Set group permissions for other user
123207
new_group_id = other_logged_user["primary_gid"]
124208
new_group_access_rights = {"read": True, "write": True, "execute": False}
@@ -156,17 +240,6 @@ async def test_function_workflow(
156240
new_group_id: new_group_access_rights
157241
}
158242

159-
# List existing functions
160-
url = client.app.router["list_functions"].url_for()
161-
response = await client.get(url)
162-
data, error = await assert_status(response, expected_list)
163-
if not error:
164-
retrieved_functions = TypeAdapter(list[RegisteredFunctionGet]).validate_python(
165-
data
166-
)
167-
assert len(retrieved_functions) == 1
168-
assert retrieved_functions[0].uid == returned_function_uid
169-
170243
# Update existing function
171244
new_title = "Test Function (edited)"
172245
new_description = "A test function (edited)"

0 commit comments

Comments
 (0)