Skip to content

Commit e6e1581

Browse files
authored
🐛 Fixes webserver-api error on get_project_services when project services are missing in the catalog by allowing partial failure (ITISFoundation#8486)
1 parent ff5ca9c commit e6e1581

File tree

27 files changed

+783
-213
lines changed

27 files changed

+783
-213
lines changed

packages/models-library/src/models_library/api_schemas_catalog/services.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pydantic import ConfigDict, Field, HttpUrl, NonNegativeInt
77
from pydantic.config import JsonDict
88

9+
from ..batch_operations import BatchGetEnvelope
910
from ..boot_options import BootOptions
1011
from ..emails import LowerCaseEmailStr
1112
from ..groups import GroupID
@@ -420,6 +421,46 @@ class MyServiceGet(CatalogOutputSchema):
420421
my_access_rights: ServiceGroupAccessRightsV2
421422

422423

424+
class MyServicesRpcBatchGet(
425+
CatalogOutputSchema,
426+
BatchGetEnvelope[MyServiceGet, tuple[ServiceKey, ServiceVersion]],
427+
):
428+
"""Result for batch get user services operations"""
429+
430+
@staticmethod
431+
def _update_json_schema_extra(schema: JsonDict) -> None:
432+
missing: Any = [("simcore/services/comp/itis/sleeper", "100.2.3")]
433+
schema.update(
434+
{
435+
"examples": [
436+
{
437+
"found_items": [
438+
{
439+
"key": missing[0][0],
440+
"release": {
441+
"version": "2.2.1",
442+
"version_display": "Winter Release",
443+
"released": "2026-07-21T15:00:00",
444+
},
445+
"owner": 42,
446+
"my_access_rights": {
447+
"execute": True,
448+
"write": False,
449+
},
450+
}
451+
],
452+
"missing_identifiers": missing,
453+
}
454+
]
455+
}
456+
)
457+
458+
model_config = ConfigDict(
459+
extra="forbid",
460+
json_schema_extra=_update_json_schema_extra,
461+
)
462+
463+
423464
class ServiceListFilters(Filters):
424465
service_type: Annotated[
425466
ServiceType | None,

packages/models-library/src/models_library/api_schemas_webserver/projects_nodes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from ..projects_nodes import InputID, InputsDict, PartialNode
1313
from ..projects_nodes_io import NodeID
1414
from ..services import ServiceKey, ServicePortKey, ServiceVersion
15+
from ..services_base import ServiceKeyVersion
1516
from ..services_enums import ServiceState
1617
from ..services_history import ServiceRelease
1718
from ..services_resources import ServiceResourcesDict
@@ -225,3 +226,9 @@ class NodeServiceGet(OutputSchema):
225226
class ProjectNodeServicesGet(OutputSchema):
226227
project_uuid: ProjectID
227228
services: list[NodeServiceGet]
229+
missing: Annotated[
230+
list[ServiceKeyVersion] | None,
231+
Field(
232+
description="List of services defined in the project but that were not found in the catalog"
233+
),
234+
] = None
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from typing import Annotated, Generic, TypeVar
2+
3+
from common_library.basic_types import DEFAULT_FACTORY
4+
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter
5+
6+
ResourceT = TypeVar("ResourceT")
7+
IdentifierT = TypeVar("IdentifierT")
8+
SchemaT = TypeVar("SchemaT")
9+
10+
11+
def _deduplicate_preserving_order(identifiers: list[IdentifierT]) -> list[IdentifierT]:
12+
"""Remove duplicates while preserving order of first occurrence."""
13+
return list(dict.fromkeys(identifiers))
14+
15+
16+
def create_batch_ids_validator(identifier_type: type[IdentifierT]) -> TypeAdapter:
17+
"""Create a TypeAdapter for validating batch identifiers.
18+
19+
This validator ensures:
20+
- At least one identifier is provided (empty list is invalid for batch operations)
21+
- Duplicates are removed while preserving order
22+
23+
Args:
24+
identifier_type: The type of identifiers in the batch
25+
26+
Returns:
27+
TypeAdapter configured for the specific identifier type
28+
"""
29+
return TypeAdapter(
30+
Annotated[
31+
list[identifier_type], # type: ignore[valid-type]
32+
BeforeValidator(_deduplicate_preserving_order),
33+
Field(
34+
min_length=1,
35+
description="List of identifiers to batch process. Empty list is not allowed for batch operations.",
36+
),
37+
]
38+
)
39+
40+
41+
class BatchGetEnvelope(BaseModel, Generic[ResourceT, IdentifierT]):
42+
"""Generic envelope model for batch-get operations that can contain partial results.
43+
44+
This model represents the result of a batch operation where some items might be found
45+
and others might be missing. It enforces that at least one item must be found,
46+
as an empty batch operation is considered a client error.
47+
"""
48+
49+
found_items: Annotated[
50+
list[ResourceT],
51+
Field(
52+
min_length=1,
53+
description="List of successfully retrieved items. Must contain at least one item.",
54+
),
55+
]
56+
missing_identifiers: Annotated[
57+
list[IdentifierT],
58+
Field(
59+
default_factory=list,
60+
description="List of identifiers for items that were not found",
61+
),
62+
] = DEFAULT_FACTORY
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import pytest
2+
from faker import Faker
3+
from models_library.api_schemas_webserver._base import (
4+
OutputSchema as WebServerOutputSchema,
5+
)
6+
from models_library.api_schemas_webserver.projects import (
7+
ProjectGet,
8+
)
9+
from models_library.batch_operations import BatchGetEnvelope, create_batch_ids_validator
10+
from models_library.generics import Envelope
11+
from models_library.projects import ProjectID
12+
from pydantic import TypeAdapter, ValidationError
13+
14+
15+
@pytest.mark.parametrize(
16+
"identifier_type,input_ids,expected_output,should_raise",
17+
[
18+
# Valid cases - successful validation
19+
pytest.param(
20+
str, ["a", "b", "c"], ["a", "b", "c"], False, id="str_valid_no_duplicates"
21+
),
22+
pytest.param(int, [1, 2, 3], [1, 2, 3], False, id="int_valid_no_duplicates"),
23+
pytest.param(
24+
tuple,
25+
[("a", 1), ("b", 2)],
26+
[("a", 1), ("b", 2)],
27+
False,
28+
id="tuple_valid_no_duplicates",
29+
),
30+
# Deduplication cases - preserving order
31+
pytest.param(
32+
str, ["a", "b", "a", "c"], ["a", "b", "c"], False, id="str_with_duplicates"
33+
),
34+
pytest.param(int, [1, 2, 1, 3, 2], [1, 2, 3], False, id="int_with_duplicates"),
35+
pytest.param(
36+
tuple,
37+
[("a", 1), ("b", 2), ("a", 1)],
38+
[("a", 1), ("b", 2)],
39+
False,
40+
id="tuple_with_duplicates",
41+
),
42+
# Single item cases
43+
pytest.param(str, ["single"], ["single"], False, id="str_single_item"),
44+
pytest.param(int, [42], [42], False, id="int_single_item"),
45+
# Edge case - all duplicates resolve to single item
46+
pytest.param(
47+
str, ["same", "same", "same"], ["same"], False, id="str_all_duplicates"
48+
),
49+
# Error cases - empty list should raise ValidationError
50+
pytest.param(str, [], None, True, id="str_empty_list_error"),
51+
pytest.param(int, [], None, True, id="int_empty_list_error"),
52+
pytest.param(tuple, [], None, True, id="tuple_empty_list_error"),
53+
],
54+
)
55+
def test_create_batch_ids_validator(
56+
identifier_type, input_ids, expected_output, should_raise
57+
):
58+
validator = create_batch_ids_validator(identifier_type)
59+
60+
if should_raise:
61+
with pytest.raises(ValidationError) as exc_info:
62+
validator.validate_python(input_ids)
63+
64+
# Verify the error is about minimum length
65+
assert "at least 1" in str(exc_info.value).lower()
66+
else:
67+
result = validator.validate_python(input_ids)
68+
assert result == expected_output
69+
assert len(result) >= 1 # Ensure minimum length constraint
70+
# Verify order preservation by checking first occurrence positions
71+
if len(set(input_ids)) != len(input_ids): # Had duplicates
72+
original_first_positions = {
73+
item: input_ids.index(item) for item in set(input_ids)
74+
}
75+
# Items should appear in the same relative order as their first occurrence
76+
sorted_by_original = sorted(
77+
result, key=lambda x: original_first_positions[x]
78+
)
79+
assert result == sorted_by_original
80+
81+
82+
def test_composing_schemas_for_batch_operations(faker: Faker):
83+
84+
# inner schema model
85+
class WebServerProjectBatchGetSchema(
86+
WebServerOutputSchema, BatchGetEnvelope[ProjectGet, ProjectID]
87+
): ...
88+
89+
some_projects = ProjectGet.model_json_schema()["examples"]
90+
91+
# response model
92+
response_model = Envelope[WebServerProjectBatchGetSchema].model_validate(
93+
{
94+
# NOTE: how camelcase (from WebServerOutputSchema.model_config) applies here
95+
"data": {
96+
"foundItems": some_projects,
97+
"missingIdentifiers": [ProjectID(faker.uuid4())],
98+
}
99+
}
100+
)
101+
102+
assert response_model.data is not None
103+
104+
assert response_model.data.found_items == TypeAdapter(
105+
list[ProjectGet]
106+
).validate_python(some_projects)
107+
108+
assert len(response_model.data.missing_identifiers) == 1
Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
11
from common_library.errors_classes import OsparcErrorMixin
22

33

4-
class CatalogApiBaseError(OsparcErrorMixin, Exception):
4+
class CatalogRpcError(OsparcErrorMixin, Exception):
55
pass
66

77

8-
class CatalogInconsistentError(CatalogApiBaseError):
8+
class CatalogInconsistentRpcError(CatalogRpcError):
99
msg_template = "Catalog is inconsistent: The following services are in the database but missing in the registry manifest {missing_services}"
1010

1111

12-
class CatalogItemNotFoundError(CatalogApiBaseError):
12+
class CatalogItemNotFoundRpcError(CatalogRpcError):
1313
msg_template = "{name} was not found"
1414

1515

16-
class CatalogForbiddenError(CatalogApiBaseError):
16+
class CatalogBatchNotFoundRpcError(CatalogRpcError):
17+
msg_template = "{name} were not found"
18+
19+
20+
class CatalogForbiddenRpcError(CatalogRpcError):
1721
msg_template = "Insufficient access rights for {name}"
1822

1923

20-
class CatalogNotAvailableError(CatalogApiBaseError):
21-
msg_template = "Catalog service failed unexpectedly"
24+
class CatalogNotAvailableRpcError(CatalogRpcError):
25+
msg_template = "Catalog service is currently not available"
26+
27+
28+
class CatalogBadRequestRpcError(CatalogRpcError):
29+
msg_template = "Bad request on {name}: {reason}"

packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/catalog/services.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from models_library.api_schemas_catalog import CATALOG_RPC_NAMESPACE
1515
from models_library.api_schemas_catalog.services import (
1616
LatestServiceGet,
17-
MyServiceGet,
17+
MyServicesRpcBatchGet,
1818
PageRpcLatestServiceGet,
1919
PageRpcServiceRelease,
2020
PageRpcServiceSummary,
@@ -177,7 +177,7 @@ async def batch_get_my_services(
177177
ServiceVersion,
178178
]
179179
],
180-
) -> list[MyServiceGet]:
180+
) -> MyServicesRpcBatchGet:
181181
"""
182182
Raises:
183183
ValidationError: on invalid arguments
@@ -191,8 +191,7 @@ async def batch_get_my_services(
191191
ids=ids,
192192
timeout_s=40 * RPC_REQUEST_DEFAULT_TIMEOUT_S,
193193
)
194-
assert TypeAdapter(list[MyServiceGet]).validate_python(result) is not None # nosec
195-
return cast(list[MyServiceGet], result)
194+
return TypeAdapter(MyServicesRpcBatchGet).validate_python(result)
196195

197196

198197
@validate_call(config={"arbitrary_types_allowed": True})

services/api-server/src/simcore_service_api_server/services_rpc/catalog.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
from servicelib.rabbitmq import RabbitMQRPCClient
2424
from servicelib.rabbitmq.rpc_interfaces.catalog import services as catalog_rpc
2525
from servicelib.rabbitmq.rpc_interfaces.catalog.errors import (
26-
CatalogForbiddenError,
27-
CatalogItemNotFoundError,
26+
CatalogForbiddenRpcError,
27+
CatalogItemNotFoundRpcError,
2828
)
2929

3030
from ..exceptions.backend_errors import (
@@ -134,8 +134,8 @@ async def list_all_services_summaries(
134134

135135
@_exception_mapper(
136136
rpc_exception_map={
137-
CatalogItemNotFoundError: ProgramOrSolverOrStudyNotFoundError,
138-
CatalogForbiddenError: ServiceForbiddenAccessError,
137+
CatalogItemNotFoundRpcError: ProgramOrSolverOrStudyNotFoundError,
138+
CatalogForbiddenRpcError: ServiceForbiddenAccessError,
139139
ValidationError: InvalidInputError,
140140
}
141141
)
@@ -156,8 +156,8 @@ async def get(
156156

157157
@_exception_mapper(
158158
rpc_exception_map={
159-
CatalogItemNotFoundError: ProgramOrSolverOrStudyNotFoundError,
160-
CatalogForbiddenError: ServiceForbiddenAccessError,
159+
CatalogItemNotFoundRpcError: ProgramOrSolverOrStudyNotFoundError,
160+
CatalogForbiddenRpcError: ServiceForbiddenAccessError,
161161
ValidationError: InvalidInputError,
162162
}
163163
)

services/catalog/src/simcore_service_catalog/api/rest/_services.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ async def cached_registry_services() -> dict[str, Any]:
154154
services_owner_emails,
155155
) = await asyncio.gather(
156156
cached_registry_services(),
157-
services_repo.batch_get_services_access_rights(
157+
services_repo.batch_get_services_access_rights_or_none(
158158
key_versions=services_in_db,
159159
product_name=x_simcore_products_name,
160160
),
@@ -163,6 +163,8 @@ async def cached_registry_services() -> dict[str, Any]:
163163
),
164164
)
165165

166+
services_access_rights = services_access_rights or {}
167+
166168
# NOTE: for the details of the services:
167169
# 1. we get all the services from the director-v0 (TODO: move the registry to the catalog)
168170
# 2. we filter the services using the visible ones from the db
@@ -176,7 +178,7 @@ async def cached_registry_services() -> dict[str, Any]:
176178
_compose_service_details,
177179
s,
178180
services_in_db[s["key"], s["version"]],
179-
services_access_rights[s["key"], s["version"]],
181+
services_access_rights.get((s["key"], s["version"])) or [],
180182
services_owner_emails.get(
181183
services_in_db[s["key"], s["version"]].owner or 0
182184
),

0 commit comments

Comments
 (0)