diff --git a/packages/models-library/src/models_library/api_schemas_catalog/services_ports.py b/packages/models-library/src/models_library/api_schemas_catalog/services_ports.py index 8393594b0c85..4911f8a5ebb1 100644 --- a/packages/models-library/src/models_library/api_schemas_catalog/services_ports.py +++ b/packages/models-library/src/models_library/api_schemas_catalog/services_ports.py @@ -1,6 +1,7 @@ -from typing import Any, Literal +from typing import Annotated, Any, Literal from pydantic import BaseModel, ConfigDict, Field +from pydantic.config import JsonDict from ..basic_regex import PUBLIC_VARIABLE_NAME_RE from ..services import ServiceInput, ServiceOutput @@ -10,42 +11,65 @@ update_schema_doc, ) -PortKindStr = Literal["input", "output"] - class ServicePortGet(BaseModel): - key: str = Field( - ..., - description="port identifier name", - pattern=PUBLIC_VARIABLE_NAME_RE, - title="Key name", - ) - kind: PortKindStr + key: Annotated[ + str, + Field( + description="Port identifier name", + pattern=PUBLIC_VARIABLE_NAME_RE, + title="Key name", + ), + ] + kind: Literal["input", "output"] content_media_type: str | None = None - content_schema: dict[str, Any] | None = Field( - None, - description="jsonschema for the port's value. SEE https://json-schema.org/understanding-json-schema/", - ) - model_config = ConfigDict( - json_schema_extra={ - "example": { - "key": "input_1", - "kind": "input", - "content_schema": { - "title": "Sleep interval", - "type": "integer", - "x_unit": "second", - "minimum": 0, - "maximum": 5, - }, - } + content_schema: Annotated[ + dict[str, Any] | None, + Field( + description="jsonschema for the port's value. SEE https://json-schema.org/understanding-json-schema/", + ), + ] = None + + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + example_input: dict[str, Any] = { + "key": "input_1", + "kind": "input", + "content_schema": { + "title": "Sleep interval", + "type": "integer", + "x_unit": "second", + "minimum": 0, + "maximum": 5, + }, } + schema.update( + { + "example": example_input, + "examples": [ + example_input, + { + "key": "output_1", + "kind": "output", + "content_media_type": "text/plain", + "content_schema": { + "type": "string", + "title": "File containing one random integer", + "description": "Integer is generated in range [1-9]", + }, + }, + ], + } + ) + + model_config = ConfigDict( + json_schema_extra=_update_json_schema_extra, ) @classmethod - def from_service_io( + def from_domain_model( cls, - kind: PortKindStr, + kind: Literal["input", "output"], key: str, port: ServiceInput | ServiceOutput, ) -> "ServicePortGet": diff --git a/packages/models-library/src/models_library/services_io.py b/packages/models-library/src/models_library/services_io.py index db43ee6eb6c7..838e84b41ee7 100644 --- a/packages/models-library/src/models_library/services_io.py +++ b/packages/models-library/src/models_library/services_io.py @@ -30,8 +30,6 @@ class BaseServiceIOModel(BaseModel): Base class for service input/outputs """ - ## management - ### human readable descriptors display_order: float | None = Field( None, diff --git a/packages/models-library/tests/test_api_schemas_catalog.py b/packages/models-library/tests/test_api_schemas_catalog.py index 721f27481e2a..280f74f2ee83 100644 --- a/packages/models-library/tests/test_api_schemas_catalog.py +++ b/packages/models-library/tests/test_api_schemas_catalog.py @@ -21,7 +21,7 @@ def test_service_port_with_file(): } ) - port = ServicePortGet.from_service_io("input", "input_1", io).model_dump( + port = ServicePortGet.from_domain_model("input", "input_1", io).model_dump( exclude_unset=True ) @@ -49,7 +49,7 @@ def test_service_port_with_boolean(): } ) - port = ServicePortGet.from_service_io("input", "input_1", io).model_dump( + port = ServicePortGet.from_domain_model("input", "input_1", io).model_dump( exclude_unset=True ) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/catalog_rpc_server.py b/packages/pytest-simcore/src/pytest_simcore/helpers/catalog_rpc_server.py index 02deb7f1ca0a..17a69908f8ae 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/catalog_rpc_server.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/catalog_rpc_server.py @@ -10,6 +10,7 @@ ServiceGetV2, ServiceListFilters, ) +from models_library.api_schemas_catalog.services_ports import ServicePortGet from models_library.api_schemas_webserver.catalog import ( CatalogServiceUpdate, ) @@ -136,3 +137,22 @@ async def list_my_service_history_paginated( limit=limit, offset=offset, ) + + async def get_service_ports( + self, + rpc_client: RabbitMQRPCClient, + *, + product_name: ProductName, + user_id: UserID, + service_key: ServiceKey, + service_version: ServiceVersion, + ) -> list[ServicePortGet]: + assert rpc_client + assert product_name + assert user_id + assert service_key + assert service_version + + return TypeAdapter(list[ServicePortGet]).validate_python( + ServicePortGet.model_json_schema()["examples"], + ) diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/catalog/services.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/catalog/services.py index f30f85a4d882..e5b505b10944 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/catalog/services.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/catalog/services.py @@ -22,6 +22,7 @@ ServiceRelease, ServiceUpdateV2, ) +from models_library.api_schemas_catalog.services_ports import ServicePortGet from models_library.products import ProductName from models_library.rabbitmq_basic_types import RPCMethodName from models_library.rest_pagination import PageOffsetInt @@ -222,3 +223,34 @@ async def list_my_service_history_paginated( # pylint: disable=too-many-argumen TypeAdapter(PageRpcServiceRelease).validate_python(result) is not None ) return cast(PageRpc[ServiceRelease], result) + + +@validate_call(config={"arbitrary_types_allowed": True}) +@log_decorator(_logger, level=logging.DEBUG) +async def get_service_ports( + rpc_client: RabbitMQRPCClient, + *, + product_name: ProductName, + user_id: UserID, + service_key: ServiceKey, + service_version: ServiceVersion, +) -> list[ServicePortGet]: + """Gets service ports (inputs and outputs) for a specific service version + + Raises: + ValidationError: on invalid arguments + CatalogItemNotFoundError: service not found in catalog + CatalogForbiddenError: not access rights to read this service + """ + result = await rpc_client.request( + CATALOG_RPC_NAMESPACE, + TypeAdapter(RPCMethodName).validate_python("get_service_ports"), + product_name=product_name, + user_id=user_id, + service_key=service_key, + service_version=service_version, + ) + assert ( + TypeAdapter(list[ServicePortGet]).validate_python(result) is not None + ) # nosec + return cast(list[ServicePortGet], result) diff --git a/services/api-server/src/simcore_service_api_server/services_rpc/catalog.py b/services/api-server/src/simcore_service_api_server/services_rpc/catalog.py index 8ecf61bf88f0..fc833ac124e0 100644 --- a/services/api-server/src/simcore_service_api_server/services_rpc/catalog.py +++ b/services/api-server/src/simcore_service_api_server/services_rpc/catalog.py @@ -3,6 +3,7 @@ from fastapi import Depends from models_library.api_schemas_catalog.services import LatestServiceGet, ServiceGetV2 +from models_library.api_schemas_catalog.services_ports import ServicePortGet from models_library.products import ProductName from models_library.rest_pagination import ( DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, @@ -113,3 +114,33 @@ async def get( service_key=name, service_version=version, ) + + @_exception_mapper( + rpc_exception_map={ + CatalogItemNotFoundError: ProgramOrSolverOrStudyNotFoundError, + CatalogForbiddenError: ServiceForbiddenAccessError, + ValidationError: InvalidInputError, + } + ) + async def get_service_ports( + self, + *, + product_name: ProductName, + user_id: UserID, + name: ServiceKey, + version: ServiceVersion, + ) -> list[ServicePortGet]: + """Gets service ports (inputs and outputs) for a specific service version + + Raises: + ProgramOrSolverOrStudyNotFoundError: service not found in catalog + ServiceForbiddenAccessError: no access rights to read this service + InvalidInputError: invalid input parameters + """ + return await catalog_rpc.get_service_ports( + self._client, + product_name=product_name, + user_id=user_id, + service_key=name, + service_version=version, + ) diff --git a/services/api-server/tests/unit/conftest.py b/services/api-server/tests/unit/conftest.py index bed207c212f9..ef41c0331d68 100644 --- a/services/api-server/tests/unit/conftest.py +++ b/services/api-server/tests/unit/conftest.py @@ -96,6 +96,12 @@ def get_mock_rabbitmq_rpc_client(): autospec=True, side_effect=side_effects.list_my_service_history_paginated, ), + "get_service_ports": mocker.patch.object( + catalog_rpc, + "get_service_ports", + autospec=True, + side_effect=side_effects.get_service_ports, + ), } app.dependency_overrides.pop(get_rabbitmq_rpc_client) diff --git a/services/api-server/tests/unit/test_services_catalog.py b/services/api-server/tests/unit/test_services_catalog.py index 767ec8698899..35e6e94455bf 100644 --- a/services/api-server/tests/unit/test_services_catalog.py +++ b/services/api-server/tests/unit/test_services_catalog.py @@ -76,9 +76,23 @@ async def test_catalog_service_read_solvers( assert solver.id == selected_solver.id assert solver.version == oldest_release.version + # Step 4: Get service ports for the solver + ports = await catalog_service.get_service_ports( + product_name=product_name, + user_id=user_id, + name=selected_solver.id, + version=oldest_release.version, + ) + + # Verify ports are returned and contain both inputs and outputs + assert ports, "Service ports should not be empty" + assert any(port.kind == "input" for port in ports), "Should contain input ports" + assert any(port.kind == "output" for port in ports), "Should contain output ports" + # checks calls to rpc mocked_rpc_catalog_service_api["list_services_paginated"].assert_called_once() mocked_rpc_catalog_service_api[ "list_my_service_history_paginated" ].assert_called_once() mocked_rpc_catalog_service_api["get_service"].assert_called_once() + mocked_rpc_catalog_service_api["get_service_ports"].assert_called_once() diff --git a/services/catalog/openapi.json b/services/catalog/openapi.json index 0173c51913d0..4295d0ebd1c3 100644 --- a/services/catalog/openapi.json +++ b/services/catalog/openapi.json @@ -3106,7 +3106,7 @@ "type": "string", "pattern": "^[^_\\W0-9]\\w*$", "title": "Key name", - "description": "port identifier name" + "description": "Port identifier name" }, "kind": { "type": "string", diff --git a/services/catalog/src/simcore_service_catalog/api/_dependencies/services.py b/services/catalog/src/simcore_service_catalog/api/_dependencies/services.py index 78a9e1209e2f..eb29eecfe386 100644 --- a/services/catalog/src/simcore_service_catalog/api/_dependencies/services.py +++ b/services/catalog/src/simcore_service_catalog/api/_dependencies/services.py @@ -95,9 +95,9 @@ async def get_service_from_manifest( return cast( ServiceMetaDataPublished, await manifest.get_service( + director_client=director_client, key=service_key, version=service_version, - director_client=director_client, ), ) diff --git a/services/catalog/src/simcore_service_catalog/api/rest/_services_ports.py b/services/catalog/src/simcore_service_catalog/api/rest/_services_ports.py index ce85bcae37d4..5b6d55306a68 100644 --- a/services/catalog/src/simcore_service_catalog/api/rest/_services_ports.py +++ b/services/catalog/src/simcore_service_catalog/api/rest/_services_ports.py @@ -31,10 +31,18 @@ async def list_service_ports( if service.inputs: for name, input_port in service.inputs.items(): - ports.append(ServicePortGet.from_service_io("input", name, input_port)) + ports.append( + ServicePortGet.from_domain_model( + kind="input", key=name, port=input_port + ) + ) if service.outputs: for name, output_port in service.outputs.items(): - ports.append(ServicePortGet.from_service_io("output", name, output_port)) + ports.append( + ServicePortGet.from_domain_model( + kind="output", key=name, port=output_port + ) + ) return ports diff --git a/services/catalog/src/simcore_service_catalog/api/rpc/_services.py b/services/catalog/src/simcore_service_catalog/api/rpc/_services.py index 45f0d4c0a714..f0dd58fc2fe1 100644 --- a/services/catalog/src/simcore_service_catalog/api/rpc/_services.py +++ b/services/catalog/src/simcore_service_catalog/api/rpc/_services.py @@ -11,6 +11,7 @@ ServiceListFilters, ServiceUpdateV2, ) +from models_library.api_schemas_catalog.services_ports import ServicePortGet from models_library.products import ProductName from models_library.rest_pagination import PageOffsetInt from models_library.rpc_pagination import DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, PageLimitInt @@ -198,12 +199,13 @@ async def check_for_service( """Checks whether service exists and can be accessed, otherwise it raise""" assert app.state.engine # nosec - await catalog_services.check_catalog_service( + await catalog_services.check_catalog_service_permissions( repo=ServicesRepository(app.state.engine), product_name=product_name, user_id=user_id, service_key=service_key, service_version=service_version, + permission="read", ) @@ -274,3 +276,42 @@ async def list_my_service_history_paginated( offset=offset, ), ) + + +@router.expose( + reraise_if_error_type=( + CatalogItemNotFoundError, + CatalogForbiddenError, + ValidationError, + ) +) +@log_decorator(_logger, level=logging.DEBUG) +@validate_call(config={"arbitrary_types_allowed": True}) +async def get_service_ports( + app: FastAPI, + *, + product_name: ProductName, + user_id: UserID, + service_key: ServiceKey, + service_version: ServiceVersion, +) -> list[ServicePortGet]: + """Get service ports (inputs and outputs) for a specific service version""" + assert app.state.engine # nosec + + service_ports = await catalog_services.get_user_services_ports( + repo=ServicesRepository(app.state.engine), + director_api=get_director_client(app), + product_name=product_name, + user_id=user_id, + service_key=service_key, + service_version=service_version, + ) + + return [ + ServicePortGet.from_domain_model( + kind=port.kind, + key=port.key, + port=port.port, + ) + for port in service_ports + ] diff --git a/services/catalog/src/simcore_service_catalog/models/services_ports.py b/services/catalog/src/simcore_service_catalog/models/services_ports.py new file mode 100644 index 000000000000..24e26749dc47 --- /dev/null +++ b/services/catalog/src/simcore_service_catalog/models/services_ports.py @@ -0,0 +1,15 @@ +from typing import Annotated, Literal + +from models_library.services_io import ServiceInput, ServiceOutput +from pydantic import BaseModel, Field + + +class ServicePort(BaseModel): + kind: Annotated[ + Literal["input", "output"], + Field(description="Whether this is an input or output port"), + ] + key: Annotated[ + str, Field(description="The unique identifier for this port within the service") + ] + port: ServiceInput | ServiceOutput diff --git a/services/catalog/src/simcore_service_catalog/service/catalog_services.py b/services/catalog/src/simcore_service_catalog/service/catalog_services.py index cce17206e259..883b995b1c95 100644 --- a/services/catalog/src/simcore_service_catalog/service/catalog_services.py +++ b/services/catalog/src/simcore_service_catalog/service/catalog_services.py @@ -2,6 +2,7 @@ import logging from contextlib import suppress +from typing import Literal from models_library.api_schemas_catalog.services import ( LatestServiceGet, @@ -36,6 +37,7 @@ ServiceMetaDataDBPatch, ServiceWithHistoryDBGet, ) +from ..models.services_ports import ServicePort from ..repository.groups import GroupsRepository from ..repository.services import ServicesRepository from . import manifest @@ -225,19 +227,14 @@ async def get_catalog_service( service_version: ServiceVersion, ) -> ServiceGetV2: - access_rights = await repo.get_service_access_rights( - key=service_key, - version=service_version, + access_rights = await check_catalog_service_permissions( + repo=repo, product_name=product_name, + user_id=user_id, + service_key=service_key, + service_version=service_version, + permission="read", ) - if not access_rights: - raise CatalogItemNotFoundError( - name=f"{service_key}:{service_version}", - service_key=service_key, - service_version=service_version, - user_id=user_id, - product_name=product_name, - ) service = await repo.get_service_with_history( product_name=product_name, @@ -291,32 +288,15 @@ async def update_catalog_service( product_name=product_name, ) - access_rights = await repo.get_service_access_rights( - key=service_key, version=service_version, product_name=product_name - ) - - if not access_rights: - raise CatalogItemNotFoundError( - name=f"{service_key}:{service_version}", - service_key=service_key, - service_version=service_version, - user_id=user_id, - product_name=product_name, - ) - - if not await repo.can_update_service( + # Check access rights first + access_rights = await check_catalog_service_permissions( + repo=repo, product_name=product_name, user_id=user_id, - key=service_key, - version=service_version, - ): - raise CatalogForbiddenError( - name=f"{service_key}:{service_version}", - service_key=service_key, - service_version=service_version, - user_id=user_id, - product_name=product_name, - ) + service_key=service_key, + service_version=service_version, + permission="write", + ) # Updates service_meta_data await repo.update_service( @@ -372,18 +352,28 @@ async def update_catalog_service( ) -async def check_catalog_service( +async def check_catalog_service_permissions( repo: ServicesRepository, + *, product_name: ProductName, user_id: UserID, service_key: ServiceKey, service_version: ServiceVersion, -) -> None: - """Raises if the service canot be read + permission: Literal["read", "write"], +) -> list[ServiceAccessRightsAtDB]: + """Raises if the service cannot be accessed with the specified permission level + + Args: + repo: Repository for services + product_name: Product name + user_id: User ID + service_key: Service key + service_version: Service version + permission: Permission level to check, either "read" or "write". Raises: CatalogItemNotFoundError: service (key,version) not found - CatalogForbiddenError: insufficient access rights to get read accss + CatalogForbiddenError: insufficient access rights to get the requested access """ access_rights = await repo.get_service_access_rights( @@ -400,12 +390,23 @@ async def check_catalog_service( product_name=product_name, ) - if not await repo.can_get_service( - product_name=product_name, - user_id=user_id, - key=service_key, - version=service_version, - ): + has_permission = False + if permission == "read": + has_permission = await repo.can_get_service( + product_name=product_name, + user_id=user_id, + key=service_key, + version=service_version, + ) + elif permission == "write": + has_permission = await repo.can_update_service( + product_name=product_name, + user_id=user_id, + key=service_key, + version=service_version, + ) + + if not has_permission: raise CatalogForbiddenError( name=f"{service_key}:{service_version}", service_key=service_key, @@ -414,6 +415,8 @@ async def check_catalog_service( product_name=product_name, ) + return access_rights + async def batch_get_user_services( repo: ServicesRepository, @@ -471,7 +474,7 @@ async def batch_get_user_services( if my_access_rights.execute or my_access_rights.write: history = await repo.get_service_history( # NOTE: that the service history might be different for each user - # since access rights are defined on a k:v basis + # since access rights are defined on a version basis (i.e. one use can have access to v1 but ot to v2) product_name=product_name, user_id=user_id, key=service_key, @@ -524,7 +527,7 @@ async def list_user_service_release_history( total_count, history = await repo.get_service_history_page( # NOTE: that the service history might be different for each user - # since access-rights are defined on a k:v basis + # since access rights are defined on a version basis (i.e. one use can have access to v1 but ot to v2) product_name=product_name, user_id=user_id, key=service_key, @@ -553,6 +556,40 @@ async def list_user_service_release_history( return total_count, items +async def get_user_services_ports( + repo: ServicesRepository, + director_api: DirectorClient, + *, + product_name: ProductName, + user_id: UserID, + service_key: ServiceKey, + service_version: ServiceVersion, +) -> list[ServicePort]: + """Get service ports (inputs and outputs) for a specific service version. + + Raises: + CatalogItemNotFoundError: When service is not found + CatalogForbiddenError: When user doesn't have access rights + """ + + # Check access rights first + await check_catalog_service_permissions( + repo=repo, + product_name=product_name, + user_id=user_id, + service_key=service_key, + service_version=service_version, + permission="read", + ) + + # Get service ports from manifest + return await manifest.get_service_ports( + director_client=director_api, + key=service_key, + version=service_version, + ) + + async def get_catalog_service_extras( director_api: DirectorClient, service_key: ServiceKey, service_version: VersionStr ) -> ServiceExtras: diff --git a/services/catalog/src/simcore_service_catalog/service/manifest.py b/services/catalog/src/simcore_service_catalog/service/manifest.py index f86aefdf8524..6173d2615291 100644 --- a/services/catalog/src/simcore_service_catalog/service/manifest.py +++ b/services/catalog/src/simcore_service_catalog/service/manifest.py @@ -36,6 +36,7 @@ from .._constants import DIRECTOR_CACHING_TTL from ..clients.director import DirectorClient +from ..models.services_ports import ServicePort from .function_services import get_function_service, is_function_service _logger = logging.getLogger(__name__) @@ -123,3 +124,40 @@ async def get_batch_services( tasks_group_prefix="manifest.get_batch_services", ) return batch + + +async def get_service_ports( + director_client: DirectorClient, + *, + key: ServiceKey, + version: ServiceVersion, +) -> list[ServicePort]: + """Retrieves all ports (inputs and outputs) from a service""" + ports = [] + service = await get_service( + director_client=director_client, + key=key, + version=version, + ) + + if service.inputs: + for input_name, service_input in service.inputs.items(): + ports.append( + ServicePort( + kind="input", + key=input_name, + port=service_input, + ) + ) + + if service.outputs: + for output_name, service_output in service.outputs.items(): + ports.append( + ServicePort( + kind="output", + key=output_name, + port=service_output, + ) + ) + + return ports diff --git a/services/catalog/tests/unit/test_service_manifest.py b/services/catalog/tests/unit/test_service_manifest.py index bb333cb19dcc..d2a57c098dca 100644 --- a/services/catalog/tests/unit/test_service_manifest.py +++ b/services/catalog/tests/unit/test_service_manifest.py @@ -31,19 +31,31 @@ def app_environment( ) -async def test_services_manifest_api( +@pytest.fixture +async def director_client( repository_lifespan_disabled: None, rabbitmq_and_rpc_setup_disabled: None, mocked_director_rest_api: MockRouter, app: FastAPI, -): - director_api = get_director_client(app) +) -> DirectorClient: + _client = get_director_client(app) + assert app.state.director_api == _client + assert isinstance(_client, DirectorClient) + return _client - assert app.state.director_api == director_api - assert isinstance(director_api, DirectorClient) - # LIST - all_services_map = await manifest.get_services_map(director_api) +@pytest.fixture +async def all_services_map( + director_client: DirectorClient, +) -> manifest.ServiceMetaDataPublishedDict: + return await manifest.get_services_map(director_client) + + +async def test_get_services_map( + mocked_director_rest_api: MockRouter, + director_client: DirectorClient, +): + all_services_map = await manifest.get_services_map(director_client) assert mocked_director_rest_api["list_services"].called for service in all_services_map.values(): @@ -57,22 +69,69 @@ async def test_services_manifest_api( } assert len(services_image_digest) < len(all_services_map) - # GET + +async def test_get_service( + mocked_director_rest_api: MockRouter, + director_client: DirectorClient, + all_services_map: manifest.ServiceMetaDataPublishedDict, +): + for expected_service in all_services_map.values(): service = await manifest.get_service( key=expected_service.key, version=expected_service.version, - director_client=director_api, + director_client=director_client, ) assert service == expected_service if not is_function_service(service.key): assert mocked_director_rest_api["get_service"].called - # BATCH + +async def test_get_service_ports( + director_client: DirectorClient, + all_services_map: manifest.ServiceMetaDataPublishedDict, +): + + for expected_service in all_services_map.values(): + ports = await manifest.get_service_ports( + key=expected_service.key, + version=expected_service.version, + director_client=director_client, + ) + + # Verify all ports are properly retrieved + assert isinstance(ports, list) + + # Check input ports + input_ports = [p for p in ports if p.kind == "input"] + if expected_service.inputs: + assert len(input_ports) == len(expected_service.inputs) + for port in input_ports: + assert port.key in expected_service.inputs + assert port.port == expected_service.inputs[port.key] + else: + assert not input_ports + + # Check output ports + output_ports = [p for p in ports if p.kind == "output"] + if expected_service.outputs: + assert len(output_ports) == len(expected_service.outputs) + for port in output_ports: + assert port.key in expected_service.outputs + assert port.port == expected_service.outputs[port.key] + else: + assert not output_ports + + +async def test_get_batch_services( + director_client: DirectorClient, + all_services_map: manifest.ServiceMetaDataPublishedDict, +): + for expected_services in toolz.partition(2, all_services_map.values()): selection = [(s.key, s.version) for s in expected_services] - got_services = await manifest.get_batch_services(selection, director_api) + got_services = await manifest.get_batch_services(selection, director_client) assert [(s.key, s.version) for s in got_services] == selection diff --git a/services/catalog/tests/unit/with_dbs/test_api_rpc.py b/services/catalog/tests/unit/with_dbs/test_api_rpc.py index 692efc846054..e8a041e663fa 100644 --- a/services/catalog/tests/unit/with_dbs/test_api_rpc.py +++ b/services/catalog/tests/unit/with_dbs/test_api_rpc.py @@ -23,18 +23,11 @@ from pytest_simcore.helpers.typing_env import EnvVarsDict from respx.router import MockRouter 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, ) -from servicelib.rabbitmq.rpc_interfaces.catalog.services import ( - batch_get_my_services, - check_for_service, - get_service, - list_my_service_history_paginated, - list_services_paginated, - update_service, -) pytest_simcore_core_services_selection = [ "rabbit", @@ -118,7 +111,7 @@ async def test_rpc_list_services_paginated_with_no_services_returns_empty_page( ): assert app - page = await list_services_paginated( + page = await catalog_rpc.list_services_paginated( rpc_client, product_name="not_existing_returns_no_services", user_id=user_id ) assert page.data == [] @@ -139,7 +132,7 @@ async def test_rpc_list_services_paginated_with_filters( assert app # only computational services introduced by the background_sync_task_mocked - page = await list_services_paginated( + page = await catalog_rpc.list_services_paginated( rpc_client, product_name=product_name, user_id=user_id, @@ -148,7 +141,7 @@ async def test_rpc_list_services_paginated_with_filters( assert page.meta.total == page.meta.count assert page.meta.total > 0 - page = await list_services_paginated( + page = await catalog_rpc.list_services_paginated( rpc_client, product_name=product_name, user_id=user_id, @@ -157,7 +150,7 @@ async def test_rpc_list_services_paginated_with_filters( assert page.meta.total == 0 -async def test_rpc_catalog_client( +async def test_rpc_catalog_client_workflow( background_sync_task_mocked: None, mocked_director_rest_api: MockRouter, rpc_client: RabbitMQRPCClient, @@ -168,7 +161,7 @@ async def test_rpc_catalog_client( ): assert app - page = await list_services_paginated( + page = await catalog_rpc.list_services_paginated( rpc_client, product_name=product_name, user_id=user_id ) @@ -177,14 +170,14 @@ async def test_rpc_catalog_client( service_version = page.data[0].version with pytest.raises(ValidationError): - await list_services_paginated( + await catalog_rpc.list_services_paginated( rpc_client, product_name=product_name, user_id=user_id, limit=MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE + 1, ) - got = await get_service( + got = await catalog_rpc.get_service( rpc_client, product_name=product_name, user_id=user_id, @@ -200,7 +193,7 @@ async def test_rpc_catalog_client( if (item.key == service_key and item.version == service_version) ) - updated = await update_service( + updated = await catalog_rpc.update_service( rpc_client, product_name=product_name, user_id=user_id, @@ -224,7 +217,7 @@ async def test_rpc_catalog_client( assert updated.icon is not None assert not updated.classifiers - got = await get_service( + got = await catalog_rpc.get_service( rpc_client, product_name=product_name, user_id=user_id, @@ -244,7 +237,7 @@ async def test_rpc_get_service_not_found_error( ): with pytest.raises(CatalogItemNotFoundError, match="unknown"): - await get_service( + await catalog_rpc.get_service( rpc_client, product_name=product_name, user_id=user_id, @@ -263,7 +256,7 @@ async def test_rpc_get_service_validation_error( ): with pytest.raises(ValidationError, match="service_key"): - await get_service( + await catalog_rpc.get_service( rpc_client, product_name=product_name, user_id=user_id, @@ -281,7 +274,7 @@ async def test_rpc_check_for_service( user_id: UserID, ): with pytest.raises(CatalogItemNotFoundError, match="unknown"): - await check_for_service( + await catalog_rpc.check_for_service( rpc_client, product_name=product_name, user_id=user_id, @@ -299,8 +292,6 @@ async def test_rpc_get_service_access_rights( user_id: UserID, other_user: dict[str, Any], app: FastAPI, - create_fake_service_data: Callable, - target_product: ProductName, ): assert app assert user["id"] == user_id @@ -309,7 +300,7 @@ async def test_rpc_get_service_access_rights( service_key = ServiceKey("simcore/services/comp/test-api-rpc-service-0") service_version = ServiceVersion("0.0.0") - service = await get_service( + service = await catalog_rpc.get_service( rpc_client, product_name=product_name, user_id=user_id, @@ -325,7 +316,7 @@ async def test_rpc_get_service_access_rights( # other_user does not have EXECUTE access ----------------- with pytest.raises(CatalogForbiddenError, match=service_key): - await get_service( + await catalog_rpc.get_service( rpc_client, product_name=product_name, user_id=other_user["id"], @@ -335,7 +326,7 @@ async def test_rpc_get_service_access_rights( # other_user does not have WRITE access with pytest.raises(CatalogForbiddenError, match=service_key): - await update_service( + await catalog_rpc.update_service( rpc_client, product_name=product_name, user_id=other_user["id"], @@ -349,7 +340,7 @@ async def test_rpc_get_service_access_rights( # user_id gives "x access" to other_user ------------ assert service.access_rights is not None - await update_service( + await catalog_rpc.update_service( rpc_client, product_name=product_name, user_id=user_id, @@ -367,7 +358,7 @@ async def test_rpc_get_service_access_rights( ) # other user can now GET but NOT UPDATE - await get_service( + await catalog_rpc.get_service( rpc_client, product_name=product_name, user_id=other_user["id"], @@ -376,7 +367,7 @@ async def test_rpc_get_service_access_rights( ) with pytest.raises(CatalogForbiddenError, match=service_key): - await update_service( + await catalog_rpc.update_service( rpc_client, product_name=product_name, user_id=other_user["id"], @@ -390,7 +381,7 @@ async def test_rpc_get_service_access_rights( # user_id gives "xw access" to other_user ------------------ assert service.access_rights is not None - await update_service( + await catalog_rpc.update_service( rpc_client, product_name=product_name, user_id=user_id, @@ -408,7 +399,7 @@ async def test_rpc_get_service_access_rights( ) # other_user can now update and get - await update_service( + await catalog_rpc.update_service( rpc_client, product_name=product_name, user_id=other_user["id"], @@ -419,7 +410,7 @@ async def test_rpc_get_service_access_rights( "description": "bar", }, ) - updated_service = await get_service( + updated_service = await catalog_rpc.get_service( rpc_client, product_name=product_name, user_id=other_user["id"], @@ -482,7 +473,7 @@ async def test_rpc_batch_get_my_services( (other_service_key, other_service_version), ] - my_services = await batch_get_my_services( + my_services = await catalog_rpc.batch_get_my_services( rpc_client, product_name=product_name, user_id=user_id, @@ -569,7 +560,7 @@ async def test_rpc_list_my_service_history_paginated( await services_db_tables_injector(fake_releases + unrelated_releases) # Call the RPC function - page = await list_my_service_history_paginated( + page = await catalog_rpc.list_my_service_history_paginated( rpc_client, product_name=product_name, user_id=user_id, @@ -582,3 +573,133 @@ async def test_rpc_list_my_service_history_paginated( assert len(release_history) == 2 assert release_history[0].version == service_version_2, "expected newest first" assert release_history[1].version == service_version_1 + + +async def test_rpc_get_service_ports_successful_retrieval( + background_sync_task_mocked: None, + mocked_director_rest_api: MockRouter, + rpc_client: RabbitMQRPCClient, + product_name: ProductName, + user_id: UserID, + app: FastAPI, + expected_director_rest_api_list_services: list[dict[str, Any]], +): + """Tests successful retrieval of service ports for a specific service version""" + assert app + + # Create a service with known ports + expected_service = expected_director_rest_api_list_services[0] + service_key = expected_service["key"] + service_version = expected_service["version"] + + # Call the RPC function to get service ports + ports = await catalog_rpc.get_service_ports( + rpc_client, + product_name=product_name, + user_id=user_id, + service_key=service_key, + service_version=service_version, + ) + + # Validate the response + expected_inputs = expected_service["inputs"] + expected_outputs = expected_service["outputs"] + assert len(ports) == len(expected_inputs) + len(expected_outputs) + + +async def test_rpc_get_service_ports_not_found( + background_sync_task_mocked: None, + mocked_director_rest_api: MockRouter, + rpc_client: RabbitMQRPCClient, + product_name: ProductName, + user_id: UserID, + app: FastAPI, +): + """Tests that appropriate error is raised when service does not exist""" + assert app + + service_version = "1.0.0" + non_existent_key = "simcore/services/comp/non-existent-service" + + # Test service not found scenario + with pytest.raises(CatalogItemNotFoundError, match="non-existent-service"): + await catalog_rpc.get_service_ports( + rpc_client, + product_name=product_name, + user_id=user_id, + service_key=non_existent_key, + service_version=service_version, + ) + + +async def test_rpc_get_service_ports_permission_denied( + background_sync_task_mocked: None, + mocked_director_rest_api: MockRouter, + rpc_client: RabbitMQRPCClient, + product_name: ProductName, + user: dict[str, Any], + user_id: UserID, + other_user: dict[str, Any], + app: FastAPI, + create_fake_service_data: Callable, + services_db_tables_injector: Callable, +): + """Tests that appropriate error is raised when user doesn't have permission""" + assert app + + assert other_user["id"] != user_id + assert user["id"] == user_id + + # Create a service with restricted access + restricted_service_key = "simcore/services/comp/restricted-service" + service_version = "1.0.0" + + fake_restricted_service = create_fake_service_data( + restricted_service_key, + service_version, + team_access=None, + everyone_access=None, + product=product_name, + ) + + # Modify access rights to restrict access + # Remove user's access if present + if ( + "access_rights" in fake_restricted_service + and user["primary_gid"] in fake_restricted_service["access_rights"] + ): + fake_restricted_service["access_rights"].pop(user["primary_gid"]) + + await services_db_tables_injector([fake_restricted_service]) + + # Attempt to access without permission + with pytest.raises(CatalogForbiddenError): + await catalog_rpc.get_service_ports( + rpc_client, + product_name=product_name, + user_id=other_user["id"], # Use a different user ID + service_key=restricted_service_key, + service_version=service_version, + ) + + +async def test_rpc_get_service_ports_validation_error( + background_sync_task_mocked: None, + mocked_director_rest_api: MockRouter, + rpc_client: RabbitMQRPCClient, + product_name: ProductName, + user_id: UserID, + app: FastAPI, +): + """Tests validation error handling for invalid service key format""" + assert app + + # Test with invalid service key format + with pytest.raises(ValidationError, match="service_key"): + await catalog_rpc.get_service_ports( + rpc_client, + product_name=product_name, + user_id=user_id, + service_key="invalid-service-key-format", + service_version="1.0.0", + )