Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1acfd80
minor unrelated
pcrespov Oct 8, 2025
3da4f75
models and errors
pcrespov Oct 8, 2025
f81ae20
service layer
pcrespov Oct 8, 2025
200008e
create models for batch operations
pcrespov Oct 8, 2025
d1de2dd
updates catalog service model
pcrespov Oct 8, 2025
eb27ed8
catalog rpc server and client
pcrespov Oct 8, 2025
509e8a4
webserver
pcrespov Oct 8, 2025
bce8a91
adds examples and adapts rpc tests
pcrespov Oct 8, 2025
46d3917
add missing services handling in project services response
pcrespov Oct 8, 2025
e63a734
updates OAS
pcrespov Oct 8, 2025
19db174
services/webserver api version: 0.79.0 → 0.80.0
pcrespov Oct 8, 2025
d99c2c4
adapts webserver service
pcrespov Oct 8, 2025
6c9869d
add tests for batch operations schema composition and validation
pcrespov Oct 8, 2025
40b8e9d
adjst batch_get in database
pcrespov Oct 8, 2025
031ce59
minor
pcrespov Oct 8, 2025
65b0ba0
refactor: update batch_ids_validator to use tuple type directly and i…
pcrespov Oct 8, 2025
bdef1f5
refactor: rename catalog error classes to include 'Rpc' suffix for cl…
pcrespov Oct 8, 2025
3083e10
fixes sonar
pcrespov Oct 8, 2025
deb0572
reduce complexity
pcrespov Oct 8, 2025
dc9eb48
fixes tests
pcrespov Oct 8, 2025
d42977d
msgs
pcrespov Oct 8, 2025
4f7c133
cleanup
pcrespov Oct 8, 2025
e4ff230
@giancarloromeo review: imports
pcrespov Oct 9, 2025
ef2e57c
@GitHK review: imports
pcrespov Oct 9, 2025
df71c9a
@GitHK review: note
pcrespov Oct 9, 2025
5fd0e88
updates oAS
pcrespov Oct 9, 2025
a7127d8
Merge branch 'master' into fix/catalog-batch-service
pcrespov Oct 9, 2025
407c7a3
Merge branch 'master' into fix/catalog-batch-service
pcrespov Oct 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pydantic import ConfigDict, Field, HttpUrl, NonNegativeInt
from pydantic.config import JsonDict

from ..batch_operations import BatchGetEnvelope
from ..boot_options import BootOptions
from ..emails import LowerCaseEmailStr
from ..groups import GroupID
Expand Down Expand Up @@ -420,6 +421,46 @@ class MyServiceGet(CatalogOutputSchema):
my_access_rights: ServiceGroupAccessRightsV2


class MyServicesRpcBatchGet(
CatalogOutputSchema,
BatchGetEnvelope[MyServiceGet, tuple[ServiceKey, ServiceVersion]],
):
"""Result for batch get user services operations"""

@staticmethod
def _update_json_schema_extra(schema: JsonDict) -> None:
missing: Any = [("simcore/services/comp/itis/sleeper", "100.2.3")]
schema.update(
{
"examples": [
{
"found_items": [
{
"key": missing[0][0],
"release": {
"version": "2.2.1",
"version_display": "Winter Release",
"released": "2026-07-21T15:00:00",
},
"owner": 42,
"my_access_rights": {
"execute": True,
"write": False,
},
}
],
"missing_identifiers": missing,
}
]
}
)

model_config = ConfigDict(
extra="forbid",
json_schema_extra=_update_json_schema_extra,
)


class ServiceListFilters(Filters):
service_type: Annotated[
ServiceType | None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ..projects_nodes import InputID, InputsDict, PartialNode
from ..projects_nodes_io import NodeID
from ..services import ServiceKey, ServicePortKey, ServiceVersion
from ..services_base import ServiceKeyVersion
from ..services_enums import ServiceState
from ..services_history import ServiceRelease
from ..services_resources import ServiceResourcesDict
Expand Down Expand Up @@ -225,3 +226,9 @@ class NodeServiceGet(OutputSchema):
class ProjectNodeServicesGet(OutputSchema):
project_uuid: ProjectID
services: list[NodeServiceGet]
missing: Annotated[
list[ServiceKeyVersion] | None,
Field(
description="List of services defined in the project but that were not found in the catalog"
),
] = None
62 changes: 62 additions & 0 deletions packages/models-library/src/models_library/batch_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from typing import Annotated, Generic, TypeVar

from common_library.basic_types import DEFAULT_FACTORY
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter

ResourceT = TypeVar("ResourceT")
IdentifierT = TypeVar("IdentifierT")
SchemaT = TypeVar("SchemaT")


def _deduplicate_preserving_order(identifiers: list[IdentifierT]) -> list[IdentifierT]:
"""Remove duplicates while preserving order of first occurrence."""
return list(dict.fromkeys(identifiers))


def create_batch_ids_validator(identifier_type: type[IdentifierT]) -> TypeAdapter:
"""Create a TypeAdapter for validating batch identifiers.

This validator ensures:
- At least one identifier is provided (empty list is invalid for batch operations)
- Duplicates are removed while preserving order

Args:
identifier_type: The type of identifiers in the batch

Returns:
TypeAdapter configured for the specific identifier type
"""
return TypeAdapter(
Annotated[
list[identifier_type], # type: ignore[valid-type]
BeforeValidator(_deduplicate_preserving_order),
Field(
min_length=1,
description="List of identifiers to batch process. Empty list is not allowed for batch operations.",
),
]
)


class BatchGetEnvelope(BaseModel, Generic[ResourceT, IdentifierT]):
"""Generic envelope model for batch-get operations that can contain partial results.

This model represents the result of a batch operation where some items might be found
and others might be missing. It enforces that at least one item must be found,
as an empty batch operation is considered a client error.
"""

found_items: Annotated[
list[ResourceT],
Field(
min_length=1,
description="List of successfully retrieved items. Must contain at least one item.",
),
]
missing_identifiers: Annotated[
list[IdentifierT],
Field(
default_factory=list,
description="List of identifiers for items that were not found",
),
] = DEFAULT_FACTORY
108 changes: 108 additions & 0 deletions packages/models-library/tests/test_batch_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import pytest
from faker import Faker
from models_library.api_schemas_webserver._base import (
OutputSchema as WebServerOutputSchema,
)
from models_library.api_schemas_webserver.projects import (
ProjectGet,
)
from models_library.batch_operations import BatchGetEnvelope, create_batch_ids_validator
from models_library.generics import Envelope
from models_library.projects import ProjectID
from pydantic import TypeAdapter, ValidationError


@pytest.mark.parametrize(
"identifier_type,input_ids,expected_output,should_raise",
[
# Valid cases - successful validation
pytest.param(
str, ["a", "b", "c"], ["a", "b", "c"], False, id="str_valid_no_duplicates"
),
pytest.param(int, [1, 2, 3], [1, 2, 3], False, id="int_valid_no_duplicates"),
pytest.param(
tuple,
[("a", 1), ("b", 2)],
[("a", 1), ("b", 2)],
False,
id="tuple_valid_no_duplicates",
),
# Deduplication cases - preserving order
pytest.param(
str, ["a", "b", "a", "c"], ["a", "b", "c"], False, id="str_with_duplicates"
),
pytest.param(int, [1, 2, 1, 3, 2], [1, 2, 3], False, id="int_with_duplicates"),
pytest.param(
tuple,
[("a", 1), ("b", 2), ("a", 1)],
[("a", 1), ("b", 2)],
False,
id="tuple_with_duplicates",
),
# Single item cases
pytest.param(str, ["single"], ["single"], False, id="str_single_item"),
pytest.param(int, [42], [42], False, id="int_single_item"),
# Edge case - all duplicates resolve to single item
pytest.param(
str, ["same", "same", "same"], ["same"], False, id="str_all_duplicates"
),
# Error cases - empty list should raise ValidationError
pytest.param(str, [], None, True, id="str_empty_list_error"),
pytest.param(int, [], None, True, id="int_empty_list_error"),
pytest.param(tuple, [], None, True, id="tuple_empty_list_error"),
],
)
def test_create_batch_ids_validator(
identifier_type, input_ids, expected_output, should_raise
):
validator = create_batch_ids_validator(identifier_type)

if should_raise:
with pytest.raises(ValidationError) as exc_info:
validator.validate_python(input_ids)

# Verify the error is about minimum length
assert "at least 1" in str(exc_info.value).lower()
else:
result = validator.validate_python(input_ids)
assert result == expected_output
assert len(result) >= 1 # Ensure minimum length constraint
# Verify order preservation by checking first occurrence positions
if len(set(input_ids)) != len(input_ids): # Had duplicates
original_first_positions = {
item: input_ids.index(item) for item in set(input_ids)
}
# Items should appear in the same relative order as their first occurrence
sorted_by_original = sorted(
result, key=lambda x: original_first_positions[x]
)
assert result == sorted_by_original


def test_composing_schemas_for_batch_operations(faker: Faker):

# inner schema model
class WebServerProjectBatchGetSchema(
WebServerOutputSchema, BatchGetEnvelope[ProjectGet, ProjectID]
): ...

some_projects = ProjectGet.model_json_schema()["examples"]

# response model
response_model = Envelope[WebServerProjectBatchGetSchema].model_validate(
{
# NOTE: how camelcase (from WebServerOutputSchema.model_config) applies here
"data": {
"foundItems": some_projects,
"missingIdentifiers": [ProjectID(faker.uuid4())],
}
}
)

assert response_model.data is not None

assert response_model.data.found_items == TypeAdapter(
list[ProjectGet]
).validate_python(some_projects)

assert len(response_model.data.missing_identifiers) == 1
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
from common_library.errors_classes import OsparcErrorMixin


class CatalogApiBaseError(OsparcErrorMixin, Exception):
class CatalogRpcError(OsparcErrorMixin, Exception):
pass


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


class CatalogItemNotFoundError(CatalogApiBaseError):
class CatalogItemNotFoundRpcError(CatalogRpcError):
msg_template = "{name} was not found"


class CatalogForbiddenError(CatalogApiBaseError):
class CatalogBatchNotFoundRpcError(CatalogRpcError):
msg_template = "{name} were not found"


class CatalogForbiddenRpcError(CatalogRpcError):
msg_template = "Insufficient access rights for {name}"


class CatalogNotAvailableError(CatalogApiBaseError):
msg_template = "Catalog service failed unexpectedly"
class CatalogNotAvailableRpcError(CatalogRpcError):
msg_template = "Catalog service is currently not available"


class CatalogBadRequestRpcError(CatalogRpcError):
msg_template = "Bad request on {name}: {reason}"
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from models_library.api_schemas_catalog import CATALOG_RPC_NAMESPACE
from models_library.api_schemas_catalog.services import (
LatestServiceGet,
MyServiceGet,
MyServicesRpcBatchGet,
PageRpcLatestServiceGet,
PageRpcServiceRelease,
PageRpcServiceSummary,
Expand Down Expand Up @@ -177,7 +177,7 @@ async def batch_get_my_services(
ServiceVersion,
]
],
) -> list[MyServiceGet]:
) -> MyServicesRpcBatchGet:
"""
Raises:
ValidationError: on invalid arguments
Expand All @@ -191,8 +191,7 @@ async def batch_get_my_services(
ids=ids,
timeout_s=40 * RPC_REQUEST_DEFAULT_TIMEOUT_S,
)
assert TypeAdapter(list[MyServiceGet]).validate_python(result) is not None # nosec
return cast(list[MyServiceGet], result)
return TypeAdapter(MyServicesRpcBatchGet).validate_python(result)


@validate_call(config={"arbitrary_types_allowed": True})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
from servicelib.rabbitmq import RabbitMQRPCClient
from servicelib.rabbitmq.rpc_interfaces.catalog import services as catalog_rpc
from servicelib.rabbitmq.rpc_interfaces.catalog.errors import (
CatalogForbiddenError,
CatalogItemNotFoundError,
CatalogForbiddenRpcError,
CatalogItemNotFoundRpcError,
)

from ..exceptions.backend_errors import (
Expand Down Expand Up @@ -134,8 +134,8 @@ async def list_all_services_summaries(

@_exception_mapper(
rpc_exception_map={
CatalogItemNotFoundError: ProgramOrSolverOrStudyNotFoundError,
CatalogForbiddenError: ServiceForbiddenAccessError,
CatalogItemNotFoundRpcError: ProgramOrSolverOrStudyNotFoundError,
CatalogForbiddenRpcError: ServiceForbiddenAccessError,
ValidationError: InvalidInputError,
}
)
Expand All @@ -156,8 +156,8 @@ async def get(

@_exception_mapper(
rpc_exception_map={
CatalogItemNotFoundError: ProgramOrSolverOrStudyNotFoundError,
CatalogForbiddenError: ServiceForbiddenAccessError,
CatalogItemNotFoundRpcError: ProgramOrSolverOrStudyNotFoundError,
CatalogForbiddenRpcError: ServiceForbiddenAccessError,
ValidationError: InvalidInputError,
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ async def cached_registry_services() -> dict[str, Any]:
services_owner_emails,
) = await asyncio.gather(
cached_registry_services(),
services_repo.batch_get_services_access_rights(
services_repo.batch_get_services_access_rights_or_none(
key_versions=services_in_db,
product_name=x_simcore_products_name,
),
Expand All @@ -163,6 +163,8 @@ async def cached_registry_services() -> dict[str, Any]:
),
)

services_access_rights = services_access_rights or {}

# NOTE: for the details of the services:
# 1. we get all the services from the director-v0 (TODO: move the registry to the catalog)
# 2. we filter the services using the visible ones from the db
Expand All @@ -176,7 +178,7 @@ async def cached_registry_services() -> dict[str, Any]:
_compose_service_details,
s,
services_in_db[s["key"], s["version"]],
services_access_rights[s["key"], s["version"]],
services_access_rights.get((s["key"], s["version"])) or [],
services_owner_emails.get(
services_in_db[s["key"], s["version"]].owner or 0
),
Expand Down
Loading
Loading