diff --git a/api/specs/webserver/openapi-projects-ports.yaml b/api/specs/webserver/openapi-projects-ports.yaml index 06267415555..af24df97b13 100644 --- a/api/specs/webserver/openapi-projects-ports.yaml +++ b/api/specs/webserver/openapi-projects-ports.yaml @@ -20,7 +20,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_dict_uuid.UUID__simcore_service_webserver.projects.projects_ports_handlers.ProjectPortGet__' + $ref: '#/components/schemas/Envelope_dict_uuid.UUID__simcore_service_webserver.projects.projects_ports_handlers.ProjectInputGet__' patch: tags: - project @@ -42,7 +42,7 @@ paths: title: Updates type: array items: - $ref: '#/components/schemas/ProjectPort' + $ref: '#/components/schemas/ProjectInputUpdate' required: true responses: '200': @@ -50,7 +50,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_dict_uuid.UUID__simcore_service_webserver.projects.projects_ports_handlers.ProjectPortGet__' + $ref: '#/components/schemas/Envelope_dict_uuid.UUID__simcore_service_webserver.projects.projects_ports_handlers.ProjectInputGet__' /projects/{project_id}/outputs: get: tags: @@ -72,18 +72,62 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_dict_uuid.UUID__simcore_service_webserver.projects.projects_ports_handlers.ProjectPortGet__' + $ref: '#/components/schemas/Envelope_dict_uuid.UUID__simcore_service_webserver.projects.projects_ports_handlers.ProjectOutputGet__' + /projects/{project_id}/metadata/ports: + get: + tags: + - project + summary: List Project Metadata Ports + description: New in version *0.12* + operationId: list_project_metadata_ports + parameters: + - required: true + schema: + title: Project Id + type: string + format: uuid + name: project_id + in: path + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_simcore_service_webserver.projects.projects_ports_handlers.ProjectMetadataPortGet__' components: schemas: - Envelope_dict_uuid.UUID__simcore_service_webserver.projects.projects_ports_handlers.ProjectPortGet__: - title: Envelope[dict[uuid.UUID, simcore_service_webserver.projects.projects_ports_handlers.ProjectPortGet]] + Envelope_dict_uuid.UUID__simcore_service_webserver.projects.projects_ports_handlers.ProjectInputGet__: + title: Envelope[dict[uuid.UUID, simcore_service_webserver.projects.projects_ports_handlers.ProjectInputGet]] + type: object + properties: + data: + title: Data + type: object + additionalProperties: + $ref: '#/components/schemas/ProjectInputGet' + error: + title: Error + Envelope_dict_uuid.UUID__simcore_service_webserver.projects.projects_ports_handlers.ProjectOutputGet__: + title: Envelope[dict[uuid.UUID, simcore_service_webserver.projects.projects_ports_handlers.ProjectOutputGet]] type: object properties: data: title: Data type: object additionalProperties: - $ref: '#/components/schemas/ProjectPortGet' + $ref: '#/components/schemas/ProjectOutputGet' + error: + title: Error + Envelope_list_simcore_service_webserver.projects.projects_ports_handlers.ProjectMetadataPortGet__: + title: Envelope[list[simcore_service_webserver.projects.projects_ports_handlers.ProjectMetadataPortGet]] + type: object + properties: + data: + title: Data + type: array + items: + $ref: '#/components/schemas/ProjectMetadataPortGet' error: title: Error HTTPValidationError: @@ -95,11 +139,12 @@ components: type: array items: $ref: '#/components/schemas/ValidationError' - ProjectPort: - title: ProjectPort + ProjectInputGet: + title: ProjectInputGet required: - key - value + - label type: object properties: key: @@ -111,8 +156,50 @@ components: value: title: Value description: Value assigned to this i/o port - ProjectPortGet: - title: ProjectPortGet + label: + title: Label + type: string + ProjectInputUpdate: + title: ProjectInputUpdate + required: + - key + - value + type: object + properties: + key: + title: Key + type: string + description: Project port's unique identifer. Same as the UUID of the associated + port node + format: uuid + value: + title: Value + description: Value assigned to this i/o port + ProjectMetadataPortGet: + title: ProjectMetadataPortGet + required: + - key + - kind + type: object + properties: + key: + title: Key + type: string + description: Project port's unique identifer. Same as the UUID of the associated + port node + format: uuid + kind: + title: Kind + enum: + - input + - output + type: string + content_schema: + title: Content Schema + type: object + description: jsonschema for the port's value. SEE https://json-schema.org/understanding-json-schema/ + ProjectOutputGet: + title: ProjectOutputGet required: - key - value diff --git a/api/specs/webserver/openapi.yaml b/api/specs/webserver/openapi.yaml index bbc7ac4902e..1f672e860ed 100644 --- a/api/specs/webserver/openapi.yaml +++ b/api/specs/webserver/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: "osparc-simcore web API" - version: 0.11.0 + version: 0.12.0 description: "API designed for the front-end app" contact: name: IT'IS Foundation @@ -240,6 +240,9 @@ paths: /projects/{project_id}/outputs: $ref: "./openapi-projects-ports.yaml#/paths/~1projects~1{project_id}~1outputs" + /projects/{project_id}/metadata/ports: + $ref: "./openapi-projects-ports.yaml#/paths/~1projects~1{project_id}~1metadata~1ports" + /nodes/{nodeInstanceUUID}/outputUi/{outputKey}: $ref: "./openapi-node-v0.0.1.yaml#/paths/~1nodes~1{nodeInstanceUUID}~1outputUi~1{outputKey}" diff --git a/api/specs/webserver/scripts/openapi_projects_ports.py b/api/specs/webserver/scripts/openapi_projects_ports.py index b084cc47a1c..67389d0432f 100644 --- a/api/specs/webserver/scripts/openapi_projects_ports.py +++ b/api/specs/webserver/scripts/openapi_projects_ports.py @@ -1,4 +1,6 @@ -""" Helper script to generate OAS automatically +""" Helper script to automatically generate OAS + +This OAS are the source of truth """ # pylint: disable=redefined-outer-name @@ -15,13 +17,12 @@ from models_library.projects import ProjectID from models_library.projects_nodes import NodeID from simcore_service_webserver.projects.projects_ports_handlers import ( - ProjectPort, - ProjectPortGet, + ProjectInputGet, + ProjectInputUpdate, + ProjectMetadataPortGet, + ProjectOutputGet, ) -# TODO: how to ensure this is in sync with projects_ports_handlers.routes ?? -# this is the source of truth. - app = FastAPI(redoc_url=None) TAGS: list[Union[str, Enum]] = [ @@ -31,7 +32,7 @@ @app.get( "/projects/{project_id}/inputs", - response_model=Envelope[dict[NodeID, ProjectPortGet]], + response_model=Envelope[dict[NodeID, ProjectInputGet]], tags=TAGS, operation_id="get_project_inputs", ) @@ -41,17 +42,19 @@ async def get_project_inputs(project_id: ProjectID): @app.patch( "/projects/{project_id}/inputs", - response_model=Envelope[dict[NodeID, ProjectPortGet]], + response_model=Envelope[dict[NodeID, ProjectInputGet]], tags=TAGS, operation_id="update_project_inputs", ) -async def update_project_inputs(project_id: ProjectID, updates: list[ProjectPort]): +async def update_project_inputs( + project_id: ProjectID, updates: list[ProjectInputUpdate] +): """New in version *0.10*""" @app.get( "/projects/{project_id}/outputs", - response_model=Envelope[dict[NodeID, ProjectPortGet]], + response_model=Envelope[dict[NodeID, ProjectOutputGet]], tags=TAGS, operation_id="get_project_outputs", ) @@ -59,6 +62,16 @@ async def get_project_outputs(project_id: ProjectID): """New in version *0.10*""" +@app.get( + "/projects/{project_id}/metadata/ports", + response_model=Envelope[list[ProjectMetadataPortGet]], + tags=TAGS, + operation_id="list_project_metadata_ports", +) +async def list_project_metadata_ports(project_id: ProjectID): + """New in version *0.12*""" + + if __name__ == "__main__": from _common import CURRENT_DIR, create_openapi_specs diff --git a/packages/models-library/src/models_library/utils/services_io.py b/packages/models-library/src/models_library/utils/services_io.py index f75d5d73732..67225495e49 100644 --- a/packages/models-library/src/models_library/utils/services_io.py +++ b/packages/models-library/src/models_library/utils/services_io.py @@ -8,6 +8,7 @@ from ..services_constants import PROPERTY_TYPE_TO_PYTHON_TYPE_MAP PortKindStr = Literal["input", "output"] +JsonSchemaDict = dict[str, Any] _PROPERTY_TYPE_TO_SCHEMAS = { property_type: schema_of(python_type, title=property_type.capitalize()) @@ -36,7 +37,7 @@ def update_schema_doc(schema: dict[str, Any], port: Union[ServiceInput, ServiceO def get_service_io_json_schema( port: Union[ServiceInput, ServiceOutput] -) -> Optional[dict[str, Any]]: +) -> Optional[JsonSchemaDict]: """Get json-schema for a i/o service For legacy metadata with property_type = integer, etc ... , it applies a conversion diff --git a/services/web/server/VERSION b/services/web/server/VERSION index d9df1bbc0c7..ac454c6a1fc 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.11.0 +0.12.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index d735446b988..88242a31019 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.11.0 +current_version = 0.12.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index e314a6cbada..f7c700f8d4d 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: osparc-simcore web API - version: 0.11.0 + version: 0.12.0 description: API designed for the front-end app contact: name: IT'IS Foundation @@ -3124,14 +3124,14 @@ paths: content: application/json: schema: - title: 'Envelope[dict[uuid.UUID, simcore_service_webserver.projects.projects_ports_handlers.ProjectPortGet]]' + title: 'Envelope[dict[uuid.UUID, simcore_service_webserver.projects.projects_ports_handlers.ProjectInputGet]]' type: object properties: data: title: Data type: object additionalProperties: - title: ProjectPortGet + title: ProjectInputGet required: - key - value @@ -3172,7 +3172,7 @@ paths: title: Updates type: array items: - title: ProjectPort + title: ProjectInputUpdate required: - key - value @@ -3215,7 +3215,84 @@ paths: content: application/json: schema: - $ref: '#/paths/~1projects~1%7Bproject_id%7D~1inputs/get/responses/200/content/application~1json/schema' + title: 'Envelope[dict[uuid.UUID, simcore_service_webserver.projects.projects_ports_handlers.ProjectOutputGet]]' + type: object + properties: + data: + title: Data + type: object + additionalProperties: + title: ProjectOutputGet + required: + - key + - value + - label + type: object + properties: + key: + title: Key + type: string + description: Project port's unique identifer. Same as the UUID of the associated port node + format: uuid + value: + title: Value + description: Value assigned to this i/o port + label: + title: Label + type: string + error: + title: Error + '/projects/{project_id}/metadata/ports': + get: + tags: + - project + summary: List Project Metadata Ports + description: New in version *0.12* + operationId: list_project_metadata_ports + parameters: + - required: true + schema: + title: Project Id + type: string + format: uuid + name: project_id + in: path + responses: + '200': + description: Successful Response + content: + application/json: + schema: + title: 'Envelope[list[simcore_service_webserver.projects.projects_ports_handlers.ProjectMetadataPortGet]]' + type: object + properties: + data: + title: Data + type: array + items: + title: ProjectMetadataPortGet + required: + - key + - kind + type: object + properties: + key: + title: Key + type: string + description: Project port's unique identifer. Same as the UUID of the associated port node + format: uuid + kind: + title: Kind + enum: + - input + - output + type: string + content_schema: + title: Content Schema + type: object + description: 'jsonschema for the port''s value. SEE https://json-schema.org/understanding-json-schema/' + error: + title: Error '/nodes/{nodeInstanceUUID}/outputUi/{outputKey}': get: tags: diff --git a/services/web/server/src/simcore_service_webserver/projects/_ports.py b/services/web/server/src/simcore_service_webserver/projects/_ports.py index 0f483eb0eed..a4de97926f8 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_ports.py +++ b/services/web/server/src/simcore_service_webserver/projects/_ports.py @@ -8,12 +8,12 @@ JsonSchemaValidationError, jsonschema_validate_data, ) -from models_library.utils.services_io import get_service_io_json_schema +from models_library.utils.services_io import JsonSchemaDict, get_service_io_json_schema from pydantic import ValidationError @dataclass(frozen=True) -class _ProjectPort: +class ProjectPortData: kind: Literal["input", "output"] node_id: NodeID io_key: str @@ -23,21 +23,30 @@ class _ProjectPort: def key(self): return f"{self.node_id}.{self.io_key}" - def get_schema(self) -> Optional[dict[str, Any]]: + def get_schema(self) -> Optional[JsonSchemaDict]: node_meta = catalog.get_metadata(self.node.key, self.node.version) if self.kind == "input" and node_meta.outputs: if input_meta := node_meta.outputs[self.io_key]: - return get_service_io_json_schema(input_meta) + return self._get_port_schema(input_meta) elif self.kind == "output" and node_meta.inputs: if output_meta := node_meta.inputs[self.io_key]: - return get_service_io_json_schema(output_meta) + return self._get_port_schema(output_meta) return None + def _get_port_schema(self, io_meta): + schema = get_service_io_json_schema(io_meta) + if schema: + # uses node label instead of service title + # This way it will contain the label the user + # gave to the port + schema["title"] = self.node.label + return schema -def _iter_project_ports( + +def iter_project_ports( workbench: dict[NodeID, Node], filter_kind: Optional[Literal["input", "output"]] = None, -) -> Iterator[_ProjectPort]: +) -> Iterator[ProjectPortData]: """Iterates the nodes in a workbench that define the input/output ports of a project - A node in general has inputs and outputs: @@ -63,7 +72,7 @@ def _iter_project_ports( assert list(node.outputs.keys()) == ["out_1"] # nosec for output_key in node.outputs.keys(): - yield _ProjectPort( + yield ProjectPortData( kind="input", node_id=node_id, io_key=output_key, node=node ) @@ -79,7 +88,7 @@ def _iter_project_ports( assert list(node.inputs.keys()) == ["in_1"] # nosec for inputs_key in node.inputs.keys(): - yield _ProjectPort( + yield ProjectPortData( kind="output", node_id=node_id, io_key=inputs_key, node=node ) @@ -87,7 +96,7 @@ def _iter_project_ports( def get_project_inputs(workbench: dict[NodeID, Node]) -> dict[NodeID, Any]: """Returns the values assigned to each input node""" input_to_value = {} - for port in _iter_project_ports(workbench, "input"): + for port in iter_project_ports(workbench, "input"): input_to_value[port.node_id] = ( port.node.outputs["out_1"] if port.node.outputs else None ) @@ -113,7 +122,7 @@ def set_project_inputs( if (node := workbench[node_id]) and node.outputs != output: # validates value against jsonschema try: - port = _ProjectPort( + port = ProjectPortData( kind="input", node_id=node_id, io_key="out_1", node=node ) if schema := port.get_schema(): @@ -136,7 +145,7 @@ class Config(PortLink.Config): def get_project_outputs(workbench: dict[NodeID, Node]) -> dict[NodeID, Any]: """Returns values assigned to each output node""" output_to_value = {} - for port in _iter_project_ports(workbench, "output"): + for port in iter_project_ports(workbench, "output"): if port.node.inputs: try: # Is link? diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_ports_handlers.py b/services/web/server/src/simcore_service_webserver/projects/projects_ports_handlers.py index e47db8b9463..539b9c99459 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_ports_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_ports_handlers.py @@ -5,14 +5,15 @@ import functools import logging -from typing import Any +from typing import Any, Literal, Optional from aiohttp import web from models_library.projects import ProjectID from models_library.projects_nodes import Node, NodeID from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder -from pydantic import BaseModel, Field, parse_obj_as +from models_library.utils.services_io import JsonSchemaDict +from pydantic import BaseModel, Extra, Field, parse_obj_as from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, @@ -82,14 +83,30 @@ async def _get_validated_workbench_model( return workbench -# +routes = web.RouteTableDef() + + +class _InputSchema: + class Config: + allow_population_by_field_name = False + extra = Extra.forbid + allow_mutations = False + + +class _OutputSchema: + class Config: + allow_population_by_field_name = True + extra = Extra.ignore + allow_mutations = False + + # + + # projects/*/inputs COLLECTION ------------------------- # -routes = web.RouteTableDef() - -class ProjectPort(BaseModel): +class _ProjectIOBase(BaseModel): key: NodeID = Field( ..., description="Project port's unique identifer. Same as the UUID of the associated port node", @@ -97,7 +114,15 @@ class ProjectPort(BaseModel): value: Any = Field(..., description="Value assigned to this i/o port") -class ProjectPortGet(ProjectPort): +class ProjectInputUpdate(_InputSchema, _ProjectIOBase): + ... + + +class ProjectInputGet(_OutputSchema, _ProjectIOBase): + label: str + + +class ProjectOutputGet(_OutputSchema, _ProjectIOBase): label: str @@ -118,7 +143,7 @@ async def get_project_inputs(request: web.Request) -> web.Response: return _web_json_response_enveloped( data={ - node_id: ProjectPortGet( + node_id: ProjectInputGet( key=node_id, label=workbench[node_id].label, value=value ) for node_id, value in inputs.items() @@ -134,7 +159,7 @@ async def update_project_inputs(request: web.Request) -> web.Response: db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(request.app) req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) - inputs_updates = await parse_request_body_as(list[ProjectPort], request) + inputs_updates = await parse_request_body_as(list[ProjectInputUpdate], request) assert request.app # nosec @@ -168,7 +193,7 @@ async def update_project_inputs(request: web.Request) -> web.Response: return _web_json_response_enveloped( data={ - node_id: ProjectPortGet( + node_id: ProjectInputGet( key=node_id, label=workbench[node_id].label, value=value ) for node_id, value in inputs.items() @@ -198,9 +223,55 @@ async def get_project_outputs(request: web.Request) -> web.Response: return _web_json_response_enveloped( data={ - node_id: ProjectPortGet( + node_id: ProjectOutputGet( key=node_id, label=workbench[node_id].label, value=value ) for node_id, value in outputs.items() } ) + + +# +# projects/*/metadata/ports sub-collection ------------------------- +# + + +class ProjectMetadataPortGet(BaseModel): + key: NodeID = Field( + ..., + description="Project port's unique identifer. Same as the UUID of the associated port node", + ) + kind: Literal["input", "output"] + content_schema: Optional[JsonSchemaDict] = Field( + None, + description="jsonschema for the port's value. SEE https://json-schema.org/understanding-json-schema/", + ) + + +@routes.get( + f"/{VTAG}/projects/{{project_id}}/metadata/ports", + name="list_project_metadata_ports", +) +@login_required +@permission_required("project.read") +@_handle_project_exceptions +async def list_project_metadata_ports(request: web.Request) -> web.Response: + req_ctx = RequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(ProjectPathParams, request) + + assert request.app # nosec + + workbench = await _get_validated_workbench_model( + app=request.app, project_id=path_params.project_id, user_id=req_ctx.user_id + ) + + return _web_json_response_enveloped( + data=[ + ProjectMetadataPortGet( + key=port.node_id, + kind=port.kind, + content_schema=port.get_schema(), + ) + for port in _ports.iter_project_ports(workbench) + ] + ) diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects__ports.py b/services/web/server/tests/unit/with_dbs/02/test_projects__ports.py index c713bbba1ea..726e7c30ba2 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects__ports.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects__ports.py @@ -12,9 +12,9 @@ from pydantic import parse_obj_as from simcore_service_webserver.projects._ports import ( InvalidInputValue, - _iter_project_ports, get_project_inputs, get_project_outputs, + iter_project_ports, set_project_inputs, ) @@ -210,7 +210,7 @@ def test_get_project_outputs(workbench: dict[NodeID, Node]): def test_project_port_get_schema(workbench): - for port in _iter_project_ports(workbench): + for port in iter_project_ports(workbench): # eval json-schema schema = port.get_schema() assert schema diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_ports_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_ports_handlers.py index 1e5ca3da3fa..5e52a7cf3e8 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_ports_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_ports_handlers.py @@ -208,6 +208,66 @@ async def test_io_workflow( project_id = user_project["uuid"] + # list_project_metadata_ports + expected_url = client.app.router["list_project_metadata_ports"].url_for( + project_id=project_id + ) + assert URL(f"/v0/projects/{project_id}/metadata/ports") == expected_url + + resp = await client.get(f"/v0/projects/{project_id}/metadata/ports") + ports_meta, error = await assert_status(resp, expected_cls=expected) + + if not error: + assert ports_meta == [ + { + "key": "38a0d401-af4b-4ea7-ab4c-5005c712a546", + "kind": "input", + "content_schema": { + "description": "Parameter of type integer", + "title": "X", + "type": "integer", + }, + }, + { + "key": "fc48252a-9dbb-4e07-bf9a-7af65a18f612", + "kind": "input", + "content_schema": { + "description": "Parameter of type integer", + "title": "Z", + "type": "integer", + }, + }, + { + "key": "7bf0741f-bae4-410b-b662-fc34b47c27c9", + "kind": "input", + "content_schema": { + "description": "Parameter of type boolean", + "title": "on", + "type": "boolean", + }, + }, + { + "key": "09fd512e-0768-44ca-81fa-0cecab74ec1a", + "kind": "output", + "content_schema": { + "default": 0, + "description": "Captures integer values attached to it", + "title": "Random sleep interval_2", + "type": "integer", + }, + }, + { + "key": "76f607b4-8761-4f96-824d-cab670bc45f5", + "kind": "output", + "content_schema": { + "default": 0, + "description": "Captures integer values attached to it", + "title": "Random sleep interval", + "type": "integer", + }, + }, + ] + # get_project_inputs expected_url = client.app.router["get_project_inputs"].url_for( project_id=project_id