Skip to content

Commit 0f538c8

Browse files
committed
adds filters to the public rest API
1 parent 94ba0a8 commit 0f538c8

File tree

6 files changed

+274
-12
lines changed

6 files changed

+274
-12
lines changed

packages/models-library/src/models_library/rpc/webserver/projects.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Annotated, TypeAlias
33
from uuid import uuid4
44

5-
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
5+
from pydantic import BaseModel, ConfigDict, Field
66
from pydantic.config import JsonDict
77

88
from ...projects import NodesDict, ProjectID
@@ -11,16 +11,8 @@
1111

1212

1313
class MetadataFilterItem(BaseModel):
14-
name: Annotated[
15-
str,
16-
StringConstraints(min_length=1, max_length=255),
17-
Field(description="Name fo the custom metadata field"),
18-
]
19-
pattern: Annotated[
20-
str,
21-
StringConstraints(min_length=1, max_length=255),
22-
Field(description="Exact value or glob pattern"),
23-
]
14+
name: str
15+
pattern: str
2416

2517

2618
class ListProjectsMarkedAsJobRpcFilter(BaseModel):

services/api-server/src/simcore_service_api_server/_service_solvers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ async def list_jobs(
9292
*,
9393
filter_by_solver_key: SolverKeyId | None = None,
9494
filter_by_solver_version: VersionStr | None = None,
95+
filter_by_job_custom_metadata: list[dict[str, str]] | None = None,
9596
pagination_offset: PageOffsetInt = 0,
9697
pagination_limit: PageLimitInt = DEFAULT_PAGINATION_LIMIT,
9798
) -> tuple[list[Job], PageMetaInfoLimitOffset]:
@@ -114,6 +115,7 @@ async def list_jobs(
114115
# 2. list jobs under job_parent_resource_name
115116
return await self.job_service.list_jobs(
116117
job_parent_resource_name=job_parent_resource_name,
118+
filter_by_job_custom_metadata=filter_by_job_custom_metadata,
117119
pagination_offset=pagination_offset,
118120
pagination_limit=pagination_limit,
119121
)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import textwrap
2+
from typing import Annotated
3+
4+
from fastapi import Query
5+
6+
from ...models.schemas.jobs_filters import JobMetadataFilter, MetadataFilterItem
7+
8+
9+
def get_job_metadata_filter(
10+
any_: Annotated[
11+
list[str] | None,
12+
Query(
13+
alias="metadata.any",
14+
description=textwrap.dedent(
15+
"""
16+
Filters jobs based on **any** of the matches on custom metadata fields.
17+
18+
*Format*: `key:pattern` where pattern can contain glob wildcards
19+
"""
20+
),
21+
example=["key1:val*", "key2:exactval"],
22+
),
23+
] = None,
24+
) -> JobMetadataFilter | None:
25+
"""
26+
Example input:
27+
28+
/solvers/-/releases/-/jobs?metadata.any=key1:val*&metadata.any=key2:exactval
29+
30+
This will be converted to:
31+
JobMetadataFilter(
32+
any=[
33+
MetadataFilterItem(name="key1", pattern="val*"),
34+
MetadataFilterItem(name="key2", pattern="exactval"),
35+
]
36+
)
37+
38+
This is used to filter jobs based on custom metadata fields.
39+
40+
"""
41+
if not any_:
42+
return None
43+
44+
items = []
45+
for item in any_:
46+
try:
47+
name, pattern = item.split(":", 1)
48+
except ValueError:
49+
continue # or raise HTTPException
50+
items.append(MetadataFilterItem(name=name, pattern=pattern))
51+
return JobMetadataFilter(any=items)

services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs_getters.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from pydantic import HttpUrl, NonNegativeInt
1919
from pydantic.types import PositiveInt
2020
from servicelib.logging_utils import log_context
21+
from simcore_service_api_server.models.schemas.jobs_filters import JobMetadataFilter
2122
from sqlalchemy.ext.asyncio import AsyncEngine
2223
from starlette.background import BackgroundTask
2324

@@ -38,6 +39,7 @@
3839
JobMetadata,
3940
JobOutputs,
4041
)
42+
from ...models.schemas.jobs_filters import JobMetadataFilter
4143
from ...models.schemas.model_adapter import (
4244
PricingUnitGetLegacy,
4345
WalletGetWithAvailableCreditsLegacy,
@@ -55,6 +57,7 @@
5557
from ..dependencies.application import get_reverse_url_mapper
5658
from ..dependencies.authentication import get_current_user_id
5759
from ..dependencies.database import get_db_asyncpg_engine
60+
from ..dependencies.models_schemas_job_filters import get_job_metadata_filter
5861
from ..dependencies.rabbitmq import get_log_check_timeout, get_log_distributor
5962
from ..dependencies.services import get_api_client, get_solver_service
6063
from ..dependencies.webserver_http import AuthSession, get_webserver_session
@@ -131,15 +134,26 @@
131134
FMSG_CHANGELOG_NEW_IN_VERSION.format("0.8"),
132135
],
133136
),
134-
include_in_schema=False, # TO BE RELEASED in 0.8
137+
include_in_schema=True, # TO BE RELEASED in 0.8
135138
)
136139
async def list_all_solvers_jobs(
137140
page_params: Annotated[PaginationParams, Depends()],
141+
filter_job_metadata_params: Annotated[
142+
JobMetadataFilter | None, Depends(get_job_metadata_filter)
143+
],
138144
solver_service: Annotated[SolverService, Depends(get_solver_service)],
139145
url_for: Annotated[Callable, Depends(get_reverse_url_mapper)],
140146
):
141147

142148
jobs, meta = await solver_service.list_jobs(
149+
filter_by_job_custom_metadata=(
150+
[
151+
{filter_metadata.name: filter_metadata.pattern}
152+
for filter_metadata in filter_job_metadata_params.any
153+
]
154+
if filter_job_metadata_params
155+
else None
156+
),
143157
pagination_offset=page_params.offset,
144158
pagination_limit=page_params.limit,
145159
)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from typing import Annotated
2+
3+
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
4+
from pydantic.config import JsonDict
5+
6+
7+
class MetadataFilterItem(BaseModel):
8+
name: Annotated[
9+
str,
10+
StringConstraints(min_length=1, max_length=255),
11+
Field(description="Name fo the metadata field"),
12+
]
13+
pattern: Annotated[
14+
str,
15+
StringConstraints(min_length=1, max_length=255),
16+
Field(description="Exact value or glob pattern"),
17+
]
18+
19+
20+
class JobMetadataFilter(BaseModel):
21+
any: Annotated[
22+
list[MetadataFilterItem],
23+
Field(description="Matches any custom metadata field (OR logic)"),
24+
]
25+
26+
@staticmethod
27+
def _update_json_schema_extra(schema: JsonDict) -> None:
28+
schema.update(
29+
{
30+
"examples": [
31+
{
32+
"any": [
33+
{
34+
"key": "solver_type",
35+
"pattern": "FEM",
36+
},
37+
{
38+
"key": "mesh_cells",
39+
"pattern": "1*",
40+
},
41+
]
42+
},
43+
{
44+
"any": [
45+
{
46+
"key": "solver_type",
47+
"pattern": "*CFD*",
48+
}
49+
]
50+
},
51+
]
52+
}
53+
)
54+
55+
model_config = ConfigDict(
56+
json_schema_extra=_update_json_schema_extra,
57+
)
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
from typing import Annotated
2+
3+
import pytest
4+
from fastapi import Depends, FastAPI, status
5+
from fastapi.testclient import TestClient
6+
from pydantic import ValidationError
7+
from simcore_service_api_server.api.dependencies.models_schemas_job_filters import (
8+
get_job_metadata_filter,
9+
)
10+
from simcore_service_api_server.models.schemas.jobs_filters import (
11+
JobMetadataFilter,
12+
MetadataFilterItem,
13+
)
14+
15+
16+
def test_get_metadata_filter():
17+
# Test with None input
18+
assert get_job_metadata_filter(None) is None
19+
20+
# Test with empty list
21+
assert get_job_metadata_filter([]) is None
22+
23+
# Test with valid input (matching the example in the docstring)
24+
input_data = ["key1:val*", "key2:exactval"]
25+
result = get_job_metadata_filter(input_data)
26+
27+
expected = JobMetadataFilter(
28+
any=[
29+
MetadataFilterItem(name="key1", pattern="val*"),
30+
MetadataFilterItem(name="key2", pattern="exactval"),
31+
]
32+
)
33+
34+
assert result is not None
35+
assert len(result.any) == 2
36+
assert result.any[0].name == "key1"
37+
assert result.any[0].pattern == "val*"
38+
assert result.any[1].name == "key2"
39+
assert result.any[1].pattern == "exactval"
40+
assert result == expected
41+
42+
# Test with invalid input (missing colon)
43+
input_data = ["key1val", "key2:exactval"]
44+
result = get_job_metadata_filter(input_data)
45+
46+
assert result is not None
47+
assert len(result.any) == 1
48+
assert result.any[0].name == "key2"
49+
assert result.any[0].pattern == "exactval"
50+
51+
# Test with empty pattern not allowed
52+
input_data = ["key1:", "key2:exactval"]
53+
with pytest.raises(ValidationError) as exc_info:
54+
get_job_metadata_filter(input_data)
55+
56+
assert exc_info.value.errors()[0]["type"] == "string_too_short"
57+
58+
59+
def test_metadata_filter_in_api_route():
60+
# Create a test FastAPI app
61+
app = FastAPI()
62+
63+
# Define a route that uses the get_metadata_filter dependency
64+
@app.get("/test-filter")
65+
def filter_endpoint(
66+
metadata_filter: Annotated[
67+
JobMetadataFilter | None, Depends(get_job_metadata_filter)
68+
] = None,
69+
):
70+
if not metadata_filter:
71+
return {"filters": None}
72+
73+
# Convert to dict for easier comparison in test
74+
return {
75+
"filters": {
76+
"any": [
77+
{"name": item.name, "pattern": item.pattern}
78+
for item in metadata_filter.any
79+
]
80+
}
81+
}
82+
83+
# Create a test client
84+
client = TestClient(app)
85+
86+
# Test with no filter
87+
response = client.get("/test-filter")
88+
assert response.status_code == status.HTTP_200_OK
89+
assert response.json() == {"filters": None}
90+
91+
# Test with single filter
92+
response = client.get("/test-filter?metadata.any=key1:val*")
93+
assert response.status_code == status.HTTP_200_OK
94+
assert response.json() == {
95+
"filters": {"any": [{"name": "key1", "pattern": "val*"}]}
96+
}
97+
98+
# Test with multiple filters
99+
response = client.get(
100+
"/test-filter?metadata.any=key1:val*&metadata.any=key2:exactval"
101+
)
102+
assert response.status_code == status.HTTP_200_OK
103+
assert response.json() == {
104+
"filters": {
105+
"any": [
106+
{"name": "key1", "pattern": "val*"},
107+
{"name": "key2", "pattern": "exactval"},
108+
]
109+
}
110+
}
111+
112+
# Test with invalid filter (should skip the invalid one)
113+
response = client.get(
114+
"/test-filter?metadata.any=invalid&metadata.any=key2:exactval"
115+
)
116+
assert response.status_code == status.HTTP_200_OK
117+
assert response.json() == {
118+
"filters": {"any": [{"name": "key2", "pattern": "exactval"}]}
119+
}
120+
121+
# Test with URL-encoded characters
122+
# Use special characters that need encoding: space, &, =, +, /, ?
123+
encoded_query = "/test-filter?metadata.any=special%20key:value%20with%20spaces&metadata.any=symbols:a%2Bb%3Dc%26d%3F%2F"
124+
response = client.get(encoded_query)
125+
assert response.status_code == status.HTTP_200_OK
126+
assert response.json() == {
127+
"filters": {
128+
"any": [
129+
{"name": "special key", "pattern": "value with spaces"},
130+
{"name": "symbols", "pattern": "a+b=c&d?/"},
131+
]
132+
}
133+
}
134+
135+
# Test with Unicode characters
136+
unicode_query = "/test-filter?metadata.any=emoji:%F0%9F%98%8A&metadata.any=international:caf%C3%A9"
137+
response = client.get(unicode_query)
138+
assert response.status_code == status.HTTP_200_OK
139+
assert response.json() == {
140+
"filters": {
141+
"any": [
142+
{"name": "emoji", "pattern": "😊"},
143+
{"name": "international", "pattern": "café"},
144+
]
145+
}
146+
}

0 commit comments

Comments
 (0)