diff --git a/.github/workflows/ci-testing-pull-request.yml b/.github/workflows/ci-testing-pull-request.yml index f1ed7df378cf..f96240ea1ccd 100644 --- a/.github/workflows/ci-testing-pull-request.yml +++ b/.github/workflows/ci-testing-pull-request.yml @@ -39,7 +39,7 @@ jobs: - name: Check openapi specs are up to date run: | if ! ./ci/github/helpers/openapi-specs-diff.bash diff \ - https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.full_name }}/${{ github.event.after }} \ + https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.full_name }}/${{ github.event.pull_request.head.sha }} \ .; then \ echo "::error:: OAS are not up to date. Run 'make openapi-specs' to update them"; exit 1; \ fi diff --git a/packages/models-library/src/models_library/api_schemas_catalog/services.py b/packages/models-library/src/models_library/api_schemas_catalog/services.py index c2551c43cb27..bf4fc35707d4 100644 --- a/packages/models-library/src/models_library/api_schemas_catalog/services.py +++ b/packages/models-library/src/models_library/api_schemas_catalog/services.py @@ -3,6 +3,7 @@ from models_library.rpc_pagination import PageRpc from pydantic import BaseModel, ConfigDict, Field, HttpUrl, NonNegativeInt +from pydantic.config import JsonDict from ..boot_options import BootOptions from ..emails import LowerCaseEmailStr @@ -21,60 +22,6 @@ from ..services_types import ServiceKey, ServiceVersion from ..utils.change_case import snake_to_camel - -class ServiceUpdate(ServiceMetaDataEditable, ServiceAccessRights): - model_config = ConfigDict( - json_schema_extra={ - "example": { - # ServiceAccessRights - "accessRights": { - 1: { - "execute_access": False, - "write_access": False, - }, # type: ignore[dict-item] - 2: { - "execute_access": True, - "write_access": True, - }, # type: ignore[dict-item] - 44: { - "execute_access": False, - "write_access": False, - }, # type: ignore[dict-item] - }, - # ServiceMetaData = ServiceCommonData + - "name": "My Human Readable Service Name", - "thumbnail": None, - "description": "An interesting service that does something", - "classifiers": ["RRID:SCR_018997", "RRID:SCR_019001"], - "quality": { - "tsr": { - "r01": {"level": 3, "references": ""}, - "r02": {"level": 2, "references": ""}, - "r03": {"level": 0, "references": ""}, - "r04": {"level": 0, "references": ""}, - "r05": {"level": 2, "references": ""}, - "r06": {"level": 0, "references": ""}, - "r07": {"level": 0, "references": ""}, - "r08": {"level": 1, "references": ""}, - "r09": {"level": 0, "references": ""}, - "r10": {"level": 0, "references": ""}, - }, - "enabled": True, - "annotations": { - "vandv": "", - "purpose": "", - "standards": "", - "limitations": "", - "documentation": "", - "certificationLink": "", - "certificationStatus": "Uncertified", - }, - }, - } - } - ) - - _EXAMPLE_FILEPICKER: dict[str, Any] = { "name": "File Picker", "thumbnail": None, @@ -209,10 +156,14 @@ class ServiceGet( description="None when the owner email cannot be found in the database" ) + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update({"examples": [_EXAMPLE_FILEPICKER, _EXAMPLE_SLEEPER]}) + model_config = ConfigDict( extra="ignore", populate_by_name=True, - json_schema_extra={"examples": [_EXAMPLE_FILEPICKER, _EXAMPLE_SLEEPER]}, + json_schema_extra=_update_json_schema_extra, ) @@ -254,62 +205,70 @@ class ServiceGetV2(BaseModel): json_schema_extra={"default": []}, ) + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "examples": [ + { + **_EXAMPLE_SLEEPER, # v2.2.1 (latest) + "history": [ + { + "version": _EXAMPLE_SLEEPER["version"], + "version_display": "Summer Release", + "released": "2024-07-20T15:00:00", + }, + { + "version": "2.0.0", + "compatibility": { + "canUpdateTo": { + "version": _EXAMPLE_SLEEPER["version"] + }, + }, + }, + {"version": "0.9.11"}, + {"version": "0.9.10"}, + { + "version": "0.9.8", + "compatibility": { + "canUpdateTo": {"version": "0.9.11"}, + }, + }, + { + "version": "0.9.1", + "versionDisplay": "Matterhorn", + "released": "2024-01-20T18:49:17", + "compatibility": { + "can_update_to": {"version": "0.9.11"}, + }, + }, + { + "version": "0.9.0", + "retired": "2024-07-20T15:00:00", + }, + {"version": "0.8.0"}, + {"version": "0.1.0"}, + ], + }, + { + **_EXAMPLE_FILEPICKER_V2, + "history": [ + { + "version": _EXAMPLE_FILEPICKER_V2["version"], + "version_display": "Odei Release", + "released": "2025-03-25T00:00:00", + } + ], + }, + ] + } + ) + model_config = ConfigDict( extra="forbid", populate_by_name=True, alias_generator=snake_to_camel, - json_schema_extra={ - "examples": [ - { - **_EXAMPLE_SLEEPER, # v2.2.1 (latest) - "history": [ - { - "version": _EXAMPLE_SLEEPER["version"], - "version_display": "Summer Release", - "released": "2024-07-20T15:00:00", - }, - { - "version": "2.0.0", - "compatibility": { - "canUpdateTo": {"version": _EXAMPLE_SLEEPER["version"]}, - }, - }, - {"version": "0.9.11"}, - {"version": "0.9.10"}, - { - "version": "0.9.8", - "compatibility": { - "canUpdateTo": {"version": "0.9.11"}, - }, - }, - { - "version": "0.9.1", - "versionDisplay": "Matterhorn", - "released": "2024-01-20T18:49:17", - "compatibility": { - "can_update_to": {"version": "0.9.11"}, - }, - }, - { - "version": "0.9.0", - "retired": "2024-07-20T15:00:00", - }, - {"version": "0.8.0"}, - {"version": "0.1.0"}, - ], - }, - { - **_EXAMPLE_FILEPICKER_V2, - "history": [ - { - "version": _EXAMPLE_FILEPICKER_V2["version"], - "version_display": "Odei Release", - "released": "2025-03-25T00:00:00", - } - ], - }, - ] - }, + json_schema_extra=_update_json_schema_extra, ) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/catalog.py b/packages/models-library/src/models_library/api_schemas_webserver/catalog.py index c6f565973273..1391ce19ce33 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/catalog.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/catalog.py @@ -1,6 +1,7 @@ from typing import Any, TypeAlias from pydantic import ConfigDict, Field +from pydantic.config import JsonDict from pydantic.main import BaseModel from ..api_schemas_catalog import services as api_schemas_catalog_services @@ -35,37 +36,42 @@ class ServiceInputGet(ServiceInput, _BaseCommonApiExtension): ..., description="Unique name identifier for this input" ) + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "example": { + "displayOrder": 2, + "label": "Sleep Time", + "description": "Time to wait before completion", + "type": "number", + "defaultValue": 0, + "unit": "second", + "widget": {"type": "TextArea", "details": {"minHeight": 1}}, + "keyId": "input_2", + "unitLong": "seconds", + "unitShort": "sec", + }, + "examples": [ + { + "label": "Acceleration", + "description": "acceleration with units", + "type": "ref_contentSchema", + "contentSchema": { + "title": "Acceleration", + "type": "number", + "x_unit": "m/s**2", + }, + "keyId": "input_1", + "unitLong": "meter/second3", + "unitShort": "m/s3", + } + ], + } + ) + model_config = ConfigDict( - json_schema_extra={ - "example": { - "displayOrder": 2, - "label": "Sleep Time", - "description": "Time to wait before completion", - "type": "number", - "defaultValue": 0, - "unit": "second", - "widget": {"type": "TextArea", "details": {"minHeight": 1}}, - "keyId": "input_2", - "unitLong": "seconds", - "unitShort": "sec", - }, - "examples": [ - # uses content-schema - { - "label": "Acceleration", - "description": "acceleration with units", - "type": "ref_contentSchema", - "contentSchema": { - "title": "Acceleration", - "type": "number", - "x_unit": "m/s**2", - }, - "keyId": "input_1", - "unitLong": "meter/second3", - "unitShort": "m/s3", - } - ], - } + json_schema_extra=_update_json_schema_extra, ) @@ -76,19 +82,25 @@ class ServiceOutputGet(ServiceOutput, _BaseCommonApiExtension): ..., description="Unique name identifier for this input" ) - model_config = ConfigDict( - json_schema_extra={ - "example": { - "displayOrder": 2, - "label": "Time Slept", - "description": "Time the service waited before completion", - "type": "number", - "unit": "second", - "unitLong": "seconds", - "unitShort": "sec", - "keyId": "output_2", + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "example": { + "displayOrder": 2, + "label": "Time Slept", + "description": "Time the service waited before completion", + "type": "number", + "unit": "second", + "unitLong": "seconds", + "unitShort": "sec", + "keyId": "output_2", + } } - } + ) + + model_config = ConfigDict( + json_schema_extra=_update_json_schema_extra, ) @@ -97,7 +109,7 @@ class ServiceOutputGet(ServiceOutput, _BaseCommonApiExtension): _EXAMPLE_FILEPICKER: dict[str, Any] = { - **api_schemas_catalog_services.ServiceGet.model_config["json_schema_extra"]["examples"][1], # type: ignore [index,dict-item] + **api_schemas_catalog_services.ServiceGet.model_json_schema()["examples"][1], "inputs": {}, "outputs": { "outFile": { @@ -112,7 +124,7 @@ class ServiceOutputGet(ServiceOutput, _BaseCommonApiExtension): } _EXAMPLE_SLEEPER: dict[str, Any] = { - **api_schemas_catalog_services.ServiceGet.model_config["json_schema_extra"]["examples"][0], # type: ignore[index,dict-item] + **api_schemas_catalog_services.ServiceGet.model_json_schema()["examples"][0], "inputs": { "input_1": { "displayOrder": 1, @@ -222,9 +234,13 @@ class ServiceGet(api_schemas_catalog_services.ServiceGet): ..., description="outputs with extended information" ) + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update({"examples": [_EXAMPLE_FILEPICKER, _EXAMPLE_SLEEPER]}) + model_config = ConfigDict( **OutputSchema.model_config, - json_schema_extra={"examples": [_EXAMPLE_FILEPICKER, _EXAMPLE_SLEEPER]}, + json_schema_extra=_update_json_schema_extra, ) @@ -242,24 +258,30 @@ class CatalogServiceGet(api_schemas_catalog_services.ServiceGetV2): ..., description="outputs with extended information" ) + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "example": { + **api_schemas_catalog_services.ServiceGetV2.model_json_schema()[ + "examples" + ][0], + "inputs": { + f"input{i}": example + for i, example in enumerate( + ServiceInputGet.model_json_schema()["examples"] + ) + }, + "outputs": { + "outFile": ServiceOutputGet.model_json_schema()["example"] + }, + } + } + ) + model_config = ConfigDict( **OutputSchema.model_config, - json_schema_extra={ - "example": { - **api_schemas_catalog_services.ServiceGetV2.model_config["json_schema_extra"]["examples"][0], # type: ignore [index,dict-item] - "inputs": { - f"input{i}": example - for i, example in enumerate( - ServiceInputGet.model_config["json_schema_extra"]["examples"] # type: ignore[index,arg-type] - ) - }, - "outputs": { - "outFile": ServiceOutputGet.model_config["json_schema_extra"][ - "example" - ] # type: ignore[index] - }, - } - }, + json_schema_extra=_update_json_schema_extra, ) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py index 8c422894b911..4b53737a73f9 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py @@ -109,18 +109,22 @@ def _update_json_schema_extra(schema: JsonDict) -> None: @classmethod def from_domain_model(cls, item: LicensedItem) -> Self: - return cls.model_validate( { - "licensed_item_id": item.licensed_item_id, - "display_name": item.display_name, - "licensed_resource_type": item.licensed_resource_type, + **item.model_dump( + include={ + "licensed_item_id", + "display_name", + "licensed_resource_type", + "pricing_plan_id", + "created_at", + "modified_at", + }, + exclude_unset=True, + ), "licensed_resource_data": { **item.licensed_resource_data, }, - "pricing_plan_id": item.pricing_plan_id, - "created_at": item.created_at, - "modified_at": item.modified_at, } ) diff --git a/packages/models-library/src/models_library/services_access.py b/packages/models-library/src/models_library/services_access.py index 248e8f41e85b..4c4506847001 100644 --- a/packages/models-library/src/models_library/services_access.py +++ b/packages/models-library/src/models_library/services_access.py @@ -2,6 +2,8 @@ """ +from typing import Annotated + from pydantic import BaseModel, ConfigDict, Field from .groups import GroupID @@ -9,13 +11,12 @@ class ServiceGroupAccessRights(BaseModel): - execute_access: bool = Field( - default=False, - description="defines whether the group can execute the service", - ) - write_access: bool = Field( - default=False, description="defines whether the group can modify the service" - ) + execute_access: Annotated[ + bool, Field(description="defines whether the group can execute the service") + ] = False + write_access: Annotated[ + bool, Field(description="defines whether the group can modify the service") + ] = False class ServiceGroupAccessRightsV2(BaseModel): @@ -23,13 +24,17 @@ class ServiceGroupAccessRightsV2(BaseModel): write: bool = False model_config = ConfigDict( - alias_generator=snake_to_camel, populate_by_name=True, extra="forbid" + alias_generator=snake_to_camel, + populate_by_name=True, + extra="forbid", ) class ServiceAccessRights(BaseModel): - access_rights: dict[GroupID, ServiceGroupAccessRights] | None = Field( - None, - alias="accessRights", - description="service access rights per group id", - ) + access_rights: Annotated[ + dict[GroupID, ServiceGroupAccessRights] | None, + Field( + alias="accessRights", + description="service access rights per group id", + ), + ] = None diff --git a/packages/models-library/src/models_library/services_base.py b/packages/models-library/src/models_library/services_base.py index 48afb0b6c04d..2ff59e0da07d 100644 --- a/packages/models-library/src/models_library/services_base.py +++ b/packages/models-library/src/models_library/services_base.py @@ -1,6 +1,6 @@ from typing import Annotated -from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from .services_types import ServiceKey, ServiceVersion from .utils.common_validators import empty_str_to_none_pre_validator @@ -16,47 +16,55 @@ class ServiceKeyVersion(BaseModel): description="distinctive name for the node based on the docker registry path", ), ] - version: ServiceVersion = Field( - ..., - description="service version number", - ) + version: Annotated[ + ServiceVersion, + Field( + description="service version number", + ), + ] model_config = ConfigDict(frozen=True) class ServiceBaseDisplay(BaseModel): - name: str = Field( - ..., - description="Display name: short, human readable name for the node", - examples=["Fast Counter"], - ) - thumbnail: Annotated[str, HttpUrl] | None = Field( - None, - description="url to the thumbnail", - examples=[ - "https://user-images.githubusercontent.com/32800795/61083844-ff48fb00-a42c-11e9-8e63-fa2d709c8baf.png" - ], - validate_default=True, - ) - description: str = Field( - ..., - description="human readable description of the purpose of the node", - examples=[ - "Our best node type", - "The mother of all nodes, makes your numbers shine!", - ], - ) - description_ui: bool = Field( - default=False, - description="A flag to enable the `description` to be presented as a single web page (=true) or in another structured format (default=false).", - ) - - version_display: str | None = Field( - None, - description="A user-friendly or marketing name for the release." - " This can be used to reference the release in a more readable and recognizable format, such as 'Matterhorn Release,' 'Spring Update,' or 'Holiday Edition.'" - " This name is not used for version comparison but is useful for communication and documentation purposes.", - ) + name: Annotated[ + str, + Field( + description="Display name: short, human readable name for the node", + examples=["Fast Counter"], + ), + ] + thumbnail: Annotated[ + str | None, + Field( + description="URL to the service thumbnail", + validate_default=True, + ), + ] = None + description: Annotated[ + str, + Field( + description="human readable description of the purpose of the node", + examples=[ + "Our best node type", + "The mother of all nodes, makes your numbers shine!", + ], + ), + ] + description_ui: Annotated[ + bool, + Field( + description="A flag to enable the `description` to be presented as a single web page (=true) or in another structured format (default=false)." + ), + ] = False + version_display: Annotated[ + str | None, + Field( + description="A user-friendly or marketing name for the release." + "This can be used to reference the release in a more readable and recognizable format, such as 'Matterhorn Release,' 'Spring Update,' or 'Holiday Edition.' " + "This name is not used for version comparison but is useful for communication and documentation purposes." + ), + ] = None _empty_is_none = field_validator("thumbnail", mode="before")( empty_str_to_none_pre_validator diff --git a/packages/models-library/src/models_library/services_metadata_editable.py b/packages/models-library/src/models_library/services_metadata_editable.py index be0a67bb3364..2bc5b0d97c44 100644 --- a/packages/models-library/src/models_library/services_metadata_editable.py +++ b/packages/models-library/src/models_library/services_metadata_editable.py @@ -2,7 +2,9 @@ from datetime import datetime from typing import Annotated, Any -from pydantic import ConfigDict, Field, HttpUrl +from common_library.basic_types import DEFAULT_FACTORY +from pydantic import ConfigDict, Field +from pydantic.config import JsonDict from .services_base import ServiceBaseDisplay from .services_constants import LATEST_INTEGRATION_VERSION @@ -19,7 +21,7 @@ class ServiceMetaDataEditable(ServiceBaseDisplay): # Overrides ServiceBaseDisplay fields to Optional for a partial update name: str | None # type: ignore[assignment] - thumbnail: Annotated[str, HttpUrl] | None + thumbnail: str | None description: str | None # type: ignore[assignment] description_ui: bool = False version_display: str | None = None @@ -35,34 +37,38 @@ class ServiceMetaDataEditable(ServiceBaseDisplay): classifiers: list[str] | None quality: Annotated[ dict[str, Any], Field(default_factory=dict, json_schema_extra={"default": {}}) - ] + ] = DEFAULT_FACTORY - model_config = ConfigDict( - json_schema_extra={ - "example": { - "key": "simcore/services/dynamic/sim4life", - "version": "1.0.9", - "name": "sim4life", - "description": "s4l web", - "thumbnail": "https://thumbnailit.org/image", - "quality": { - "enabled": True, - "tsr_target": { - f"r{n:02d}": {"level": 4, "references": ""} - for n in range(1, 11) + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "example": { + "key": "simcore/services/dynamic/sim4life", + "version": "1.0.9", + "name": "sim4life", + "description": "s4l web", + "thumbnail": "https://thumbnailit.org/image", + "quality": { + "enabled": True, + "tsr_target": { + f"r{n:02d}": {"level": 4, "references": ""} + for n in range(1, 11) + }, + "annotations": { + "vandv": "", + "limitations": "", + "certificationLink": "", + "certificationStatus": "Uncertified", + }, + "tsr_current": { + f"r{n:02d}": {"level": 0, "references": ""} + for n in range(1, 11) + }, }, - "annotations": { - "vandv": "", - "limitations": "", - "certificationLink": "", - "certificationStatus": "Uncertified", - }, - "tsr_current": { - f"r{n:02d}": {"level": 0, "references": ""} - for n in range(1, 11) - }, - }, - "classifiers": [], + "classifiers": [], + } } - } - ) + ) + + model_config = ConfigDict(json_schema_extra=_update_json_schema_extra) diff --git a/packages/models-library/tests/test_licenses.py b/packages/models-library/tests/test_licenses.py index 893f4fcba0d7..19c00efa7152 100644 --- a/packages/models-library/tests/test_licenses.py +++ b/packages/models-library/tests/test_licenses.py @@ -20,12 +20,15 @@ def test_licensed_item_from_domain_model(): # date is required assert got.licensed_resource_data.source.features["date"] - # + # id is required assert ( got.licensed_resource_data.source.id == item.licensed_resource_data["source"]["id"] ) + # checks unset fields + assert "category_icon" not in got.licensed_resource_data.model_fields_set + def test_strict_check_of_examples(): class TestLicensedItemRestGet(LicensedItemRestGet): diff --git a/services/catalog/openapi.json b/services/catalog/openapi.json index 3389e912cf81..5fe2b0a51da5 100644 --- a/services/catalog/openapi.json +++ b/services/catalog/openapi.json @@ -603,92 +603,6 @@ } } } - }, - "patch": { - "tags": [ - "services" - ], - "summary": "Update Service", - "operationId": "update_service_v0_services__service_key___service_version__patch", - "parameters": [ - { - "name": "service_key", - "in": "path", - "required": true, - "schema": { - "type": "string", - "pattern": "^simcore/services/((comp|dynamic|frontend))/([a-z0-9][a-z0-9_.-]*/)*([a-z0-9-_]+[a-z0-9])$", - "title": "Service Key" - } - }, - { - "name": "service_version", - "in": "path", - "required": true, - "schema": { - "type": "string", - "pattern": "^(0|[1-9]\\d*)(\\.(0|[1-9]\\d*)){2}(-(0|[1-9]\\d*|\\d*[-a-zA-Z][-\\da-zA-Z]*)(\\.(0|[1-9]\\d*|\\d*[-a-zA-Z][-\\da-zA-Z]*))*)?(\\+[-\\da-zA-Z]+(\\.[-\\da-zA-Z-]+)*)?$", - "title": "Service Version" - } - }, - { - "name": "user_id", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "title": "User Id" - } - }, - { - "name": "x-simcore-products-name", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "X-Simcore-Products-Name" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceUpdate" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceGet" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } } } }, @@ -2695,17 +2609,14 @@ "thumbnail": { "anyOf": [ { - "type": "string", - "maxLength": 2083, - "minLength": 1, - "format": "uri" + "type": "string" }, { "type": "null" } ], "title": "Thumbnail", - "description": "url to the thumbnail" + "description": "URL to the service thumbnail" }, "description": { "type": "string", @@ -2728,7 +2639,7 @@ } ], "title": "Version Display", - "description": "A user-friendly or marketing name for the release. This can be used to reference the release in a more readable and recognizable format, such as 'Matterhorn Release,' 'Spring Update,' or 'Holiday Edition.' This name is not used for version comparison but is useful for communication and documentation purposes." + "description": "A user-friendly or marketing name for the release.This can be used to reference the release in a more readable and recognizable format, such as 'Matterhorn Release,' 'Spring Update,' or 'Holiday Edition.' This name is not used for version comparison but is useful for communication and documentation purposes." }, "deprecated": { "anyOf": [ @@ -3368,193 +3279,6 @@ ], "title": "ServiceType" }, - "ServiceUpdate": { - "properties": { - "accessRights": { - "anyOf": [ - { - "additionalProperties": { - "$ref": "#/components/schemas/ServiceGroupAccessRights" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Accessrights", - "description": "service access rights per group id" - }, - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Name" - }, - "thumbnail": { - "anyOf": [ - { - "type": "string", - "maxLength": 2083, - "minLength": 1, - "format": "uri" - }, - { - "type": "null" - } - ], - "title": "Thumbnail" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" - }, - "description_ui": { - "type": "boolean", - "title": "Description Ui", - "default": false - }, - "version_display": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Version Display" - }, - "deprecated": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Deprecated", - "description": "Owner can set the date to retire the service. Three possibilities:If None, the service is marked as `published`;If now=deprecated, the service is retired" - }, - "classifiers": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "title": "Classifiers" - }, - "quality": { - "type": "object", - "title": "Quality", - "default": {} - } - }, - "type": "object", - "required": [ - "name", - "thumbnail", - "description", - "classifiers" - ], - "title": "ServiceUpdate", - "example": { - "accessRights": { - "1": { - "execute_access": false, - "write_access": false - }, - "2": { - "execute_access": true, - "write_access": true - }, - "44": { - "execute_access": false, - "write_access": false - } - }, - "classifiers": [ - "RRID:SCR_018997", - "RRID:SCR_019001" - ], - "description": "An interesting service that does something", - "name": "My Human Readable Service Name", - "quality": { - "annotations": { - "certificationLink": "", - "certificationStatus": "Uncertified", - "documentation": "", - "limitations": "", - "purpose": "", - "standards": "", - "vandv": "" - }, - "enabled": true, - "tsr": { - "r01": { - "level": 3, - "references": "" - }, - "r02": { - "level": 2, - "references": "" - }, - "r03": { - "level": 0, - "references": "" - }, - "r04": { - "level": 0, - "references": "" - }, - "r05": { - "level": 2, - "references": "" - }, - "r06": { - "level": 0, - "references": "" - }, - "r07": { - "level": 0, - "references": "" - }, - "r08": { - "level": 1, - "references": "" - }, - "r09": { - "level": 0, - "references": "" - }, - "r10": { - "level": 0, - "references": "" - } - } - } - } - }, "Spread": { "properties": { "SpreadDescriptor": { diff --git a/services/catalog/src/simcore_service_catalog/api/rest/_services.py b/services/catalog/src/simcore_service_catalog/api/rest/_services.py index 78362d63733f..bdf12be74f14 100644 --- a/services/catalog/src/simcore_service_catalog/api/rest/_services.py +++ b/services/catalog/src/simcore_service_catalog/api/rest/_services.py @@ -2,13 +2,12 @@ import asyncio import logging -import urllib.parse from typing import Annotated, Any, TypeAlias, cast from aiocache import cached # type: ignore[import-untyped] from fastapi import APIRouter, Depends, Header, HTTPException, status -from models_library.api_schemas_catalog.services import ServiceGet, ServiceUpdate -from models_library.services import ServiceKey, ServiceType, ServiceVersion +from models_library.api_schemas_catalog.services import ServiceGet +from models_library.services import ServiceType from models_library.services_authoring import Author from models_library.services_metadata_published import ServiceMetaDataPublished from pydantic import ValidationError @@ -25,7 +24,6 @@ from ...db.repositories.services import ServicesRepository from ...models.services_db import ServiceAccessRightsAtDB, ServiceMetaDataAtDB from ...services.director import DirectorApi -from ...services.function_services import is_function_service from ..dependencies.database import get_repository from ..dependencies.director import get_director_api from ..dependencies.services import get_service_from_manifest @@ -266,121 +264,3 @@ async def get_service( | service_in_db.model_dump(exclude_unset=True, exclude={"owner"}) ) return service_data - - -@router.patch( - "/{service_key:path}/{service_version}", - response_model=ServiceGet, - **RESPONSE_MODEL_POLICY, -) -async def update_service( - # pylint: disable=too-many-arguments - user_id: int, - service_key: ServiceKey, - service_version: ServiceVersion, - updated_service: ServiceUpdate, - director_client: Annotated[DirectorApi, Depends(get_director_api)], - groups_repository: Annotated[ - GroupsRepository, Depends(get_repository(GroupsRepository)) - ], - services_repo: Annotated[ - ServicesRepository, Depends(get_repository(ServicesRepository)) - ], - x_simcore_products_name: Annotated[str | None, Header()] = None, -): - if is_function_service(service_key): - # NOTE: this is a temporary decision after discussing with OM - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Cannot update front-end services", - ) - - # check the service exists - await director_client.get( - f"/services/{urllib.parse.quote_plus(service_key)}/{service_version}" - ) - # the director client already raises an exception if not found - - # get the user groups - user_groups = await groups_repository.list_user_groups(user_id) - if not user_groups: - # deny access - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You have unsufficient rights to access the service", - ) - # check the user has write access to this service - writable_service = await services_repo.get_service( - service_key, - service_version, - gids=[group.gid for group in user_groups], - write_access=True, - product_name=x_simcore_products_name, - ) - if not writable_service: - # deny access - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You have unsufficient rights to modify the service", - ) - - # let's modify the service then - await services_repo.update_service( - ServiceMetaDataAtDB( - key=service_key, - version=service_version, - **updated_service.model_dump(exclude_unset=True), - ) - ) - # let's modify the service access rights (they can be added/removed/modified) - current_gids_in_db = [ - r.gid - for r in await services_repo.get_service_access_rights( - service_key, service_version, product_name=x_simcore_products_name - ) - ] - - if updated_service.access_rights: - # start by updating/inserting new entries - assert x_simcore_products_name # nosec - new_access_rights = [ - ServiceAccessRightsAtDB( - key=service_key, - version=service_version, - gid=gid, - execute_access=rights.execute_access, - write_access=rights.write_access, - product_name=x_simcore_products_name, - ) - for gid, rights in updated_service.access_rights.items() - ] - await services_repo.upsert_service_access_rights(new_access_rights) - - # then delete the ones that were removed - removed_gids = [ - gid - for gid in current_gids_in_db - if gid not in updated_service.access_rights - ] - deleted_access_rights = [ - ServiceAccessRightsAtDB( - key=service_key, - version=service_version, - gid=gid, - product_name=x_simcore_products_name, - ) - for gid in removed_gids - ] - await services_repo.delete_service_access_rights(deleted_access_rights) - - # now return the service - assert x_simcore_products_name # nosec - return await get_service( - user_id=user_id, - service_in_manifest=await get_service_from_manifest( - service_key, service_version, director_client - ), - groups_repository=groups_repository, - services_repo=services_repo, - x_simcore_products_name=x_simcore_products_name, - ) diff --git a/services/catalog/src/simcore_service_catalog/models/services_db.py b/services/catalog/src/simcore_service_catalog/models/services_db.py index 89a61af2e7a5..64d8f4a5fa35 100644 --- a/services/catalog/src/simcore_service_catalog/models/services_db.py +++ b/services/catalog/src/simcore_service_catalog/models/services_db.py @@ -1,12 +1,14 @@ from datetime import datetime from typing import Annotated, Any +from common_library.basic_types import DEFAULT_FACTORY from models_library.products import ProductName from models_library.services_access import ServiceGroupAccessRights from models_library.services_base import ServiceKeyVersion from models_library.services_metadata_editable import ServiceMetaDataEditable from models_library.services_types import ServiceKey, ServiceVersion -from pydantic import BaseModel, ConfigDict, Field, HttpUrl +from pydantic import BaseModel, ConfigDict, Field +from pydantic.config import JsonDict from pydantic.types import PositiveInt from simcore_postgres_database.models.services_compatibility import CompatiblePolicyDict @@ -14,46 +16,55 @@ class ServiceMetaDataAtDB(ServiceKeyVersion, ServiceMetaDataEditable): # for a partial update all Editable members must be Optional name: str | None = None - thumbnail: Annotated[str, HttpUrl] | None = None + thumbnail: str | None = None description: str | None = None - classifiers: Annotated[list[str] | None, Field(default_factory=list)] + classifiers: Annotated[ + list[str] | None, + Field(default_factory=list), + ] = DEFAULT_FACTORY + owner: PositiveInt | None = None - model_config = ConfigDict( - from_attributes=True, - json_schema_extra={ - "example": { - "key": "simcore/services/dynamic/sim4life", - "version": "1.0.9", - "owner": 8, - "name": "sim4life", - "description": "s4l web", - "description_ui": 0, - "thumbnail": "http://thumbnailit.org/image", - "version_display": "S4L X", - "created": "2021-01-18 12:46:57.7315", - "modified": "2021-01-19 12:45:00", - "deprecated": "2099-01-19 12:45:00", - "quality": { - "enabled": True, - "tsr_target": { - f"r{n:02d}": {"level": 4, "references": ""} - for n in range(1, 11) - }, - "annotations": { - "vandv": "", - "limitations": "", - "certificationLink": "", - "certificationStatus": "Uncertified", + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "example": { + "key": "simcore/services/dynamic/sim4life", + "version": "1.0.9", + "owner": 8, + "name": "sim4life", + "description": "s4l web", + "description_ui": 0, + "thumbnail": "https://picsum.photos/200", + "version_display": "S4L X", + "created": "2021-01-18 12:46:57.7315", + "modified": "2021-01-19 12:45:00", + "deprecated": "2099-01-19 12:45:00", + "quality": { + "enabled": True, + "tsr_target": { + f"r{n:02d}": {"level": 4, "references": ""} + for n in range(1, 11) + }, + "annotations": { + "vandv": "", + "limitations": "", + "certificationLink": "", + "certificationStatus": "Uncertified", + }, + "tsr_current": { + f"r{n:02d}": {"level": 0, "references": ""} + for n in range(1, 11) + }, }, - "tsr_current": { - f"r{n:02d}": {"level": 0, "references": ""} - for n in range(1, 11) - }, - }, + } } - }, + ) + + model_config = ConfigDict( + from_attributes=True, json_schema_extra=_update_json_schema_extra ) @@ -97,18 +108,24 @@ class ServiceWithHistoryFromDB(BaseModel): class ServiceAccessRightsAtDB(ServiceKeyVersion, ServiceGroupAccessRights): gid: PositiveInt product_name: ProductName - model_config = ConfigDict( - from_attributes=True, - json_schema_extra={ - "example": { - "key": "simcore/services/dynamic/sim4life", - "version": "1.0.9", - "gid": 8, - "execute_access": True, - "write_access": True, - "product_name": "osparc", - "created": "2021-01-18 12:46:57.7315", - "modified": "2021-01-19 12:45:00", + + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "example": { + "key": "simcore/services/dynamic/sim4life", + "version": "1.0.9", + "gid": 8, + "execute_access": True, + "write_access": True, + "product_name": "osparc", + "created": "2021-01-18 12:46:57.7315", + "modified": "2021-01-19 12:45:00", + } } - }, + ) + + model_config = ConfigDict( + from_attributes=True, json_schema_extra=_update_json_schema_extra ) diff --git a/services/catalog/tests/unit/with_dbs/test_api_rest_services__list.py b/services/catalog/tests/unit/with_dbs/test_api_rest_services__list.py index 8c2071fea232..301c64bf7bea 100644 --- a/services/catalog/tests/unit/with_dbs/test_api_rest_services__list.py +++ b/services/catalog/tests/unit/with_dbs/test_api_rest_services__list.py @@ -56,9 +56,9 @@ async def test_list_services_with_details( url = URL("/v0/services").with_query({"user_id": user_id, "details": "true"}) # now fake the director such that it returns half the services - fake_registry_service_data = ServiceMetaDataPublished.model_config[ - "json_schema_extra" - ]["examples"][0] + fake_registry_service_data = ServiceMetaDataPublished.model_json_schema()[ + "examples" + ][0] mocked_director_service_api_base.get("/services", name="list_services").respond( 200, @@ -262,9 +262,9 @@ async def test_list_services_that_are_deprecated( assert received_service.deprecated == deprecation_date # for details, the director must return the same service - fake_registry_service_data = ServiceMetaDataPublished.model_config[ - "json_schema_extra" - ]["examples"][0] + fake_registry_service_data = ServiceMetaDataPublished.model_json_schema()[ + "examples" + ][0] mocked_director_service_api_base.get("/services", name="list_services").respond( 200, json={ diff --git a/services/director-v2/tests/unit/with_dbs/comp_scheduler/test_api_route_computations.py b/services/director-v2/tests/unit/with_dbs/comp_scheduler/test_api_route_computations.py index 028b33ad4841..4494ba62863b 100644 --- a/services/director-v2/tests/unit/with_dbs/comp_scheduler/test_api_route_computations.py +++ b/services/director-v2/tests/unit/with_dbs/comp_scheduler/test_api_route_computations.py @@ -169,14 +169,7 @@ def _mocked_service_resources(request) -> httpx.Response: def _mocked_services_details( request, service_key: str, service_version: str ) -> httpx.Response: - assert "json_schema_extra" in ServiceGet.model_config - assert isinstance(ServiceGet.model_config["json_schema_extra"], dict) - assert isinstance( - ServiceGet.model_config["json_schema_extra"]["examples"], list - ) - assert isinstance( - ServiceGet.model_config["json_schema_extra"]["examples"][0], dict - ) + data_published = fake_service_details.model_copy( update={ "key": urllib.parse.unquote(service_key), @@ -184,7 +177,7 @@ def _mocked_services_details( } ).model_dump(by_alias=True) data = { - **ServiceGet.model_config["json_schema_extra"]["examples"][0], + **ServiceGet.model_json_schema()["examples"][0], **data_published, } payload = ServiceGet.model_validate(data) diff --git a/services/web/server/src/simcore_service_webserver/catalog/_api.py b/services/web/server/src/simcore_service_webserver/catalog/_api.py index f2fc9be73a92..9a2872351330 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/_api.py +++ b/services/web/server/src/simcore_service_webserver/catalog/_api.py @@ -1,5 +1,4 @@ import logging -import warnings from collections.abc import Iterator from typing import Any, cast @@ -183,55 +182,6 @@ async def list_services( return services -async def get_service( - service_key: ServiceKey, service_version: ServiceVersion, ctx: CatalogRequestContext -) -> dict[str, Any]: - - warnings.warn( - "`get_service` is deprecated, use `get_service_v2` instead", - DeprecationWarning, - stacklevel=1, - ) - - service = await client.get_service( - ctx.app, ctx.user_id, service_key, service_version, ctx.product_name - ) - await replace_service_input_outputs( - service, - unit_registry=ctx.unit_registry, - **RESPONSE_MODEL_POLICY, - ) - return service - - -async def update_service( - service_key: ServiceKey, - service_version: ServiceVersion, - update_data: dict[str, Any], - ctx: CatalogRequestContext, -): - warnings.warn( - "`update_service_v2` is deprecated, use `update_service_v2` instead", - DeprecationWarning, - stacklevel=1, - ) - - service = await client.update_service( - ctx.app, - ctx.user_id, - service_key, - service_version, - ctx.product_name, - update_data, - ) - await replace_service_input_outputs( - service, - unit_registry=ctx.unit_registry, - **RESPONSE_MODEL_POLICY, - ) - return service - - async def list_service_inputs( service_key: ServiceKey, service_version: ServiceVersion, ctx: CatalogRequestContext ) -> list[ServiceInputGet]: @@ -294,7 +244,7 @@ async def get_compatible_inputs_given_source_output( def iter_service_inputs() -> Iterator[tuple[ServiceInputKey, ServiceInput]]: for service_input in service_inputs: yield service_input.key_id, ServiceInput.model_construct( - **service_input.model_dump(include=ServiceInput.model_fields.keys()) # type: ignore[arg-type] + **service_input.model_dump(include=ServiceInput.model_fields.keys()) # type: ignore[arg-type] ) # check @@ -361,7 +311,7 @@ def iter_service_outputs() -> Iterator[tuple[ServiceOutputKey, ServiceOutput]]: to_service_key, to_service_version, to_input_key, ctx ) to_input: ServiceInput = ServiceInput.model_construct( - **service_input.model_dump(include=ServiceInput.model_fields.keys()) # type: ignore[arg-type] + **service_input.model_dump(include=ServiceInput.model_fields.keys()) # type: ignore[arg-type] ) # check diff --git a/services/web/server/src/simcore_service_webserver/catalog/client.py b/services/web/server/src/simcore_service_webserver/catalog/client.py index 386ae811da04..100b4e69318a 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/client.py +++ b/services/web/server/src/simcore_service_webserver/catalog/client.py @@ -169,27 +169,3 @@ async def get_service_access_rights( resp.raise_for_status() body = await resp.json() return ServiceAccessRightsGet.model_validate(body) - - -async def update_service( - app: web.Application, - user_id: UserID, - service_key: str, - service_version: str, - product_name: str, - update_data: dict[str, Any], -) -> dict[str, Any]: - settings: CatalogSettings = get_plugin_settings(app) - - url = URL( - f"{settings.api_base_url}/services/{urllib.parse.quote_plus(service_key)}/{service_version}", - encoded=True, - ).with_query({"user_id": user_id}) - - with _handle_client_exceptions(app) as session: - async with session.patch( - url, headers={X_PRODUCT_NAME_HEADER: product_name}, json=update_data - ) as resp: - resp.raise_for_status() - body: dict[str, Any] = await resp.json() - return body diff --git a/services/web/server/src/simcore_service_webserver/exception_handling/_factory.py b/services/web/server/src/simcore_service_webserver/exception_handling/_factory.py index baae399f76b1..8d05e59f2296 100644 --- a/services/web/server/src/simcore_service_webserver/exception_handling/_factory.py +++ b/services/web/server/src/simcore_service_webserver/exception_handling/_factory.py @@ -90,10 +90,13 @@ async def _exception_handler( error=exception, error_code=oec, error_context={ + # NOTE: context is also used to substitute tokens in the error message + # e.g. "support error is {error_code}" "request": request, "request.remote": f"{request.remote}", "request.method": f"{request.method}", "request.path": f"{request.path}", + "error_code": oec, }, ) ) diff --git a/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__pricing_plan.py b/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__pricing_plan.py index 35733d100e60..112513ca271d 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__pricing_plan.py +++ b/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__pricing_plan.py @@ -30,7 +30,7 @@ def mock_rut_api_responses( settings: ResourceUsageTrackerSettings = get_plugin_settings(client.app) service_pricing_plan_get = PricingPlanGet.model_validate( - PricingPlanGet.model_config["json_schema_extra"]["examples"][0], + PricingPlanGet.model_json_schema()["examples"][0], ) aioresponses_mocker.get( re.compile(f"^{settings.api_base_url}/services/+.+$"), diff --git a/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py b/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py index 96ada7579006..1bf0ce9e9bd8 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py +++ b/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py @@ -56,7 +56,7 @@ async def _list( assert user_id items = TypeAdapter(list[ServiceGetV2]).validate_python( - ServiceGetV2.model_config["json_schema_extra"]["examples"], + ServiceGetV2.model_json_schema()["examples"], ) total_count = len(items) @@ -80,7 +80,7 @@ async def _get( assert user_id got = ServiceGetV2.model_validate( - ServiceGetV2.model_config["json_schema_extra"]["examples"][0] + ServiceGetV2.model_json_schema()["examples"][0] ) got.version = service_version got.key = service_key @@ -101,7 +101,7 @@ async def _update( assert user_id got = ServiceGetV2.model_validate( - ServiceGetV2.model_config["json_schema_extra"]["examples"][0] + ServiceGetV2.model_json_schema()["examples"][0] ) got.version = service_version got.key = service_key