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