Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
@@ -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
Expand All @@ -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":
Expand Down
2 changes: 0 additions & 2 deletions packages/models-library/src/models_library/services_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ class BaseServiceIOModel(BaseModel):
Base class for service input/outputs
"""

## management

### human readable descriptors
display_order: float | None = Field(
None,
Expand Down
4 changes: 2 additions & 2 deletions packages/models-library/tests/test_api_schemas_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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"],
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
6 changes: 6 additions & 0 deletions services/api-server/tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions services/api-server/tests/unit/test_services_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 1 addition & 1 deletion services/catalog/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3106,7 +3106,7 @@
"type": "string",
"pattern": "^[^_\\W0-9]\\w*$",
"title": "Key name",
"description": "port identifier name"
"description": "Port identifier name"
},
"kind": {
"type": "string",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
)


Expand Down Expand Up @@ -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
]
Loading
Loading