diff --git a/services/api-server/VERSION b/services/api-server/VERSION index faef31a4357c..39e898a4f952 100644 --- a/services/api-server/VERSION +++ b/services/api-server/VERSION @@ -1 +1 @@ -0.7.0 +0.7.1 diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json index 8dccff0adc3a..0ed6c76b59b4 100644 --- a/services/api-server/openapi.json +++ b/services/api-server/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "osparc.io public API", "description": "osparc-simcore public API specifications", - "version": "0.7.0" + "version": "0.7.1" }, "paths": { "/v0/meta": { @@ -1423,7 +1423,7 @@ "solvers" ], "summary": "List Solvers", - "description": "\ud83d\udea8 **Deprecated**: This endpoint is deprecated and will be removed in a future release.\nPlease use `GET /v0/solvers/page` instead.\n\n\n\nLists all available solvers (latest version)\n\nNew in *version 0.5.0*\n\nRemoved in *version 0.7*: This endpoint is deprecated and will be removed in a future version", + "description": "\ud83d\udea8 **Deprecated**: This endpoint is deprecated and will be removed in a future release.\nPlease use `GET /v0/solvers/page` instead.\n\n\n\nLists all available solvers (latest version)\n\nNew in *version 0.5.0*", "operationId": "list_solvers", "responses": { "200": { @@ -1514,7 +1514,7 @@ "solvers" ], "summary": "Lists All Releases", - "description": "\ud83d\udea8 **Deprecated**: This endpoint is deprecated and will be removed in a future release.\nPlease use `GET /v0/solvers/{solver_key}/releases/page` instead.\n\n\n\nLists all released solvers (not just latest version)\n\nNew in *version 0.5.0*\n\nRemoved in *version 0.7*: This endpoint is deprecated and will be removed in a future version", + "description": "\ud83d\udea8 **Deprecated**: This endpoint is deprecated and will be removed in a future release.\nPlease use `GET /v0/solvers/{solver_key}/releases/page` instead.\n\n\n\nLists all released solvers (not just latest version)\n\nNew in *version 0.5.0*", "operationId": "list_solvers_releases", "responses": { "200": { @@ -1604,8 +1604,8 @@ "tags": [ "solvers" ], - "summary": "Get Latest Release of a Solver", - "description": "Gets latest release of a solver", + "summary": "Get Solver", + "description": "Gets latest release of a solver\n\nAdded in *version 0.7.1*: `version_display` field in the response", "operationId": "get_solver", "security": [ { @@ -1714,7 +1714,7 @@ "solvers" ], "summary": "List Solver Releases", - "description": "Lists all releases of a given (one) solver\n\nSEE get_solver_releases_page for a paginated version of this function", + "description": "Lists all releases of a given (one) solver\n\nAdded in *version 0.7.1*: `version_display` field in the response", "operationId": "list_solver_releases", "security": [ { @@ -1827,7 +1827,7 @@ "solvers" ], "summary": "Get Solver Release", - "description": "Gets a specific release of a solver", + "description": "Gets a specific release of a solver\n\nAdded in *version 0.7.1*: `version_display` field in the response", "operationId": "get_solver_release", "security": [ { @@ -1946,7 +1946,7 @@ "solvers" ], "summary": "List Solver Ports", - "description": "Lists inputs and outputs of a given solver\n\nNew in *version 0.5.0*", + "description": "Lists inputs and outputs of a given solver\n\nNew in *version 0.5.0*\n\nAdded in *version 0.7.1*: `version_display` field in the response", "operationId": "list_solver_ports", "security": [ { @@ -2065,7 +2065,7 @@ "solvers" ], "summary": "Get Solver Pricing Plan", - "description": "Gets solver pricing plan\n\nNew in *version 0.7*", + "description": "Gets solver pricing plan\n\nNew in *version 0.7*\n\nAdded in *version 0.7.1*: `version_display` field in the response", "operationId": "get_solver_pricing_plan", "security": [ { @@ -10465,6 +10465,18 @@ "type": "string", "title": "Maintainer", "description": "Maintainer of the solver" + }, + "version_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version Display", + "description": "A user-friendly or marketing name for the release." } }, "type": "object", @@ -10483,7 +10495,8 @@ "maintainer": "info@itis.swiss", "title": "iSolve", "url": "https://api.osparc.io/v0/solvers/simcore%2Fservices%2Fcomp%2Fisolve/releases/2.1.1", - "version": "2.1.1" + "version": "2.1.1", + "version_display": "2.1.1-2023-10-01" } }, "SolverFunction": { diff --git a/services/api-server/setup.cfg b/services/api-server/setup.cfg index da01c1bbd3ea..dc966c0ca5fb 100644 --- a/services/api-server/setup.cfg +++ b/services/api-server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.7.0 +current_version = 0.7.1 commit = True message = services/api-server version: {current_version} → {new_version} tag = False diff --git a/services/api-server/src/simcore_service_api_server/api/routes/solvers.py b/services/api-server/src/simcore_service_api_server/api/routes/solvers.py index d67150a8204a..49e792ef1786 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/solvers.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/solvers.py @@ -25,8 +25,8 @@ from ..dependencies.services import get_catalog_service, get_solver_service from ..dependencies.webserver_http import AuthSession, get_webserver_session from ._constants import ( + FMSG_CHANGELOG_ADDED_IN_VERSION, FMSG_CHANGELOG_NEW_IN_VERSION, - FMSG_CHANGELOG_REMOVED_IN_VERSION_FORMAT, create_route_description, ) @@ -56,10 +56,6 @@ alternative="GET /v0/solvers/page", changelog=[ FMSG_CHANGELOG_NEW_IN_VERSION.format("0.5.0", ""), - FMSG_CHANGELOG_REMOVED_IN_VERSION_FORMAT.format( - "0.7", - "This endpoint is deprecated and will be removed in a future version", - ), ], ), ) @@ -67,8 +63,6 @@ async def list_solvers( catalog_service: Annotated[CatalogService, Depends(get_catalog_service)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], ): - """Lists all available solvers (latest version)""" - services, _ = await catalog_service.list_latest_releases( filters=ServiceListFilters(service_type=ServiceType.COMPUTATIONAL), ) @@ -124,10 +118,6 @@ async def get_solvers_page( alternative="GET /v0/solvers/{solver_key}/releases/page", changelog=[ FMSG_CHANGELOG_NEW_IN_VERSION.format("0.5.0", ""), - FMSG_CHANGELOG_REMOVED_IN_VERSION_FORMAT.format( - "0.7", - "This endpoint is deprecated and will be removed in a future version", - ), ], ), ) @@ -167,21 +157,25 @@ async def list_solvers_releases( @router.get( "/{solver_key:path}/latest", response_model=Solver, - summary="Get Latest Release of a Solver", responses=_SOLVER_STATUS_CODES, + description=create_route_description( + base="Gets latest release of a solver", + changelog=[ + FMSG_CHANGELOG_ADDED_IN_VERSION.format( + "0.7.1", "`version_display` field in the response" + ), + ], + ), ) async def get_solver( solver_key: SolverKeyId, solver_service: Annotated[SolverService, Depends(get_solver_service)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], ): - """Gets latest release of a solver""" # IMPORTANT: by adding /latest, we avoid changing the order of this entry in the router list # otherwise, {solver_key:path} will override and consume any of the paths that follow. try: - solver = await solver_service.get_latest_release( - solver_key=solver_key, - ) + solver = await solver_service.get_latest_release(solver_key=solver_key) solver.url = url_for( "get_solver_release", solver_key=solver.id, version=solver.version ) @@ -199,16 +193,20 @@ async def get_solver( "/{solver_key:path}/releases", response_model=list[Solver], responses=_SOLVER_STATUS_CODES, + description=create_route_description( + base="Lists all releases of a given (one) solver", + changelog=[ + FMSG_CHANGELOG_ADDED_IN_VERSION.format( + "0.7.1", "`version_display` field in the response" + ), + ], + ), ) async def list_solver_releases( solver_key: SolverKeyId, solver_service: Annotated[SolverService, Depends(get_solver_service)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], ): - """Lists all releases of a given (one) solver - - SEE get_solver_releases_page for a paginated version of this function - """ all_releases: list[Solver] = [] for page_params in iter_pagination_params(limit=DEFAULT_PAGINATION_LIMIT): solvers, page_meta = await solver_service.solver_release_history( @@ -267,6 +265,14 @@ async def get_solver_releases_page( "/{solver_key:path}/releases/{version}", response_model=Solver, responses=_SOLVER_STATUS_CODES, + description=create_route_description( + base="Gets a specific release of a solver", + changelog=[ + FMSG_CHANGELOG_ADDED_IN_VERSION.format( + "0.7.1", "`version_display` field in the response" + ), + ], + ), ) async def get_solver_release( solver_key: SolverKeyId, @@ -274,7 +280,6 @@ async def get_solver_release( solver_service: Annotated[SolverService, Depends(get_solver_service)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], ): - """Gets a specific release of a solver""" try: solver: Solver = await solver_service.get_solver( solver_key=solver_key, @@ -302,8 +307,15 @@ async def get_solver_release( "/{solver_key:path}/releases/{version}/ports", response_model=OnePage[SolverPort], responses=_SOLVER_STATUS_CODES, - description="Lists inputs and outputs of a given solver\n\n" - + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.5.0"), + description=create_route_description( + base="Lists inputs and outputs of a given solver", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.5.0"), + FMSG_CHANGELOG_ADDED_IN_VERSION.format( + "0.7.1", "`version_display` field in the response" + ), + ], + ), ) async def list_solver_ports( solver_key: SolverKeyId, @@ -322,9 +334,16 @@ async def list_solver_ports( @router.get( "/{solver_key:path}/releases/{version}/pricing_plan", response_model=ServicePricingPlanGetLegacy, - description="Gets solver pricing plan\n\n" - + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.7"), responses=_SOLVER_STATUS_CODES, + description=create_route_description( + base="Gets solver pricing plan", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.7"), + FMSG_CHANGELOG_ADDED_IN_VERSION.format( + "0.7.1", "`version_display` field in the response" + ), + ], + ), ) async def get_solver_pricing_plan( solver_key: SolverKeyId, diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/solvers.py b/services/api-server/src/simcore_service_api_server/models/schemas/solvers.py index 79fb3ebba778..7411c24c2bd2 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/solvers.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/solvers.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any, Literal +from typing import Annotated, Any, Literal, Self from models_library.api_schemas_catalog.services import LatestServiceGet, ServiceGetV2 from models_library.basic_regex import PUBLIC_VARIABLE_NAME_RE @@ -39,7 +39,12 @@ class Solver(BaseService): """A released solver with a specific version""" - maintainer: str = Field(..., description="Maintainer of the solver") + maintainer: Annotated[str, Field(description="Maintainer of the solver")] + + version_display: Annotated[ + str | None, + Field(description="A user-friendly or marketing name for the release."), + ] = None model_config = ConfigDict( extra="ignore", @@ -47,6 +52,7 @@ class Solver(BaseService): "example": { "id": "simcore/services/comp/isolve", "version": "2.1.1", + "version_display": "2.1.1-2023-10-01", "title": "iSolve", "description": "EM solver", "maintainer": "info@itis.swiss", @@ -56,31 +62,27 @@ class Solver(BaseService): ) @classmethod - def create_from_image(cls, image_meta: ServiceMetaDataPublished) -> "Solver": - data = image_meta.model_dump( - include={"name", "key", "version", "description", "contact"}, - ) + def create_from_image(cls, image_meta: ServiceMetaDataPublished) -> Self: return cls( - id=data.pop("key"), - version=data.pop("version"), - title=data.pop("name"), - maintainer=data.pop("contact"), + id=image_meta.key, + version=image_meta.version, + title=image_meta.name, + description=image_meta.description, + maintainer=image_meta.contact, + version_display=image_meta.version_display, url=None, - **data, ) @classmethod - def create_from_service(cls, service: ServiceGetV2 | LatestServiceGet) -> "Solver": - data = service.model_dump( - include={"name", "key", "version", "description", "contact"}, - ) + def create_from_service(cls, service: ServiceGetV2 | LatestServiceGet) -> Self: return cls( - id=data.pop("key"), - version=data.pop("version"), - title=data.pop("name"), + id=service.key, + version=service.version, + title=service.name, + description=service.description, + maintainer=service.contact or "UNDEFINED", + version_display=service.version_display, url=None, - maintainer=data.pop("contact"), - **data, ) @classmethod diff --git a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers.py b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers.py index 39cf97ab0e1d..92a5cd9b202a 100644 --- a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers.py +++ b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers.py @@ -6,64 +6,83 @@ import httpx -import pytest -import simcore_service_api_server.api.routes.solvers from pydantic import TypeAdapter -from pytest_mock import MockFixture +from pytest_mock import MockType from simcore_service_api_server._meta import API_VTAG from simcore_service_api_server.models.pagination import OnePage from simcore_service_api_server.models.schemas.solvers import Solver, SolverPort from starlette import status -@pytest.mark.skip(reason="Still under development. Currently using fake implementation") -async def test_list_solvers( +async def test_list_all_solvers( + mocked_catalog_rpc_api: dict[str, MockType], client: httpx.AsyncClient, - mocker: MockFixture, + auth: httpx.BasicAuth, ): - warn = mocker.patch.object( - simcore_service_api_server.api.routes.solvers._logger, "warning" - ) + response = await client.get(f"/{API_VTAG}/solvers", auth=auth) + assert response.status_code == status.HTTP_200_OK - # list solvers latest releases - resp = await client.get("/v0/solvers") - assert resp.status_code == status.HTTP_200_OK - # No warnings for ValidationError with the fixture - assert ( - not warn.called - ), f"No warnings expected in this fixture, got {warn.call_args!s}" +async def test_list_all_solvers_paginated( + mocked_catalog_rpc_api: dict[str, MockType], + client: httpx.AsyncClient, + auth: httpx.BasicAuth, +): + response = await client.get(f"/{API_VTAG}/solvers/page", auth=auth) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["items"]) == response.json()["total"] - data = resp.json() - assert len(data) == 2 - for item in data: - solver = Solver(**item) - print(solver.model_dump_json(indent=1, exclude_unset=True)) +async def test_list_all_solvers_releases( + mocked_catalog_rpc_api: dict[str, MockType], + client: httpx.AsyncClient, + auth: httpx.BasicAuth, +): + response = await client.get(f"/{API_VTAG}/solvers/releases", auth=auth) + assert response.status_code == status.HTTP_200_OK - # use link to get the same solver - assert solver.url - assert solver.url.host == "api.testserver.io" # cli.base_url - assert solver.url.path - # get_solver_latest_version_by_name - resp0 = await client.get(solver.url.path) - assert resp0.status_code == status.HTTP_501_NOT_IMPLEMENTED - assert f"GET solver {solver.id}" in resp0.json()["errors"][0] - # get_solver - resp1 = await client.get(f"/v0/solvers/{solver.id}") - assert resp1.status_code == status.HTTP_501_NOT_IMPLEMENTED - assert f"GET solver {solver.id}" in resp1.json()["errors"][0] +async def test_list_all_solvers_releases_paginated( + mocked_catalog_rpc_api: dict[str, MockType], + client: httpx.AsyncClient, + auth: httpx.BasicAuth, +): + solver_key = "simcore/services/comp/itis/sleeper" + response = await client.get( + f"/{API_VTAG}/solvers/{solver_key}/releases/page", auth=auth + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["items"]) == response.json()["total"] + + +async def test_list_solver_releases( + mocked_catalog_rpc_api: dict[str, MockType], + client: httpx.AsyncClient, + auth: httpx.BasicAuth, +): + solver_key = "simcore/services/comp/itis/sleeper" + response = await client.get(f"/{API_VTAG}/solvers/{solver_key}/releases", auth=auth) + assert response.status_code == status.HTTP_200_OK + - # get_solver_latest_version_by_name - resp2 = await client.get(f"/v0/solvers/{solver.id}/latest") +async def test_get_solver_release( + mocked_catalog_rpc_api: dict[str, MockType], + client: httpx.AsyncClient, + auth: httpx.BasicAuth, +): + solver_key = "simcore/services/comp/itis/sleeper" + solver_version = "2.2.1" + response = await client.get( + f"/{API_VTAG}/solvers/{solver_key}/releases/{solver_version}", auth=auth + ) + assert response.status_code == status.HTTP_200_OK - assert resp2.status_code == status.HTTP_501_NOT_IMPLEMENTED - assert f"GET latest {solver.id}" in resp2.json()["errors"][0] + solver = Solver.model_validate(response.json()) + assert solver.version_display == "2 Xtreme" async def test_list_solver_ports( - mocked_catalog_rpc_api: dict, + mocked_catalog_rpc_api: dict[str, MockType], client: httpx.AsyncClient, auth: httpx.BasicAuth, ): @@ -100,60 +119,9 @@ async def test_list_solver_ports( } -async def test_list_solvers_with_mocked_catalog( - client: httpx.AsyncClient, - mocked_catalog_rpc_api: dict, - auth: httpx.BasicAuth, -): - response = await client.get(f"/{API_VTAG}/solvers", auth=auth) - assert response.status_code == status.HTTP_200_OK - - -async def test_list_releases_with_mocked_catalog( - client: httpx.AsyncClient, - mocked_catalog_rpc_api: dict, - auth: httpx.BasicAuth, -): - response = await client.get(f"/{API_VTAG}/solvers/releases", auth=auth) - assert response.status_code == status.HTTP_200_OK - - -async def test_list_solver_page_with_mocked_catalog( - client: httpx.AsyncClient, - mocked_catalog_rpc_api: dict, - auth: httpx.BasicAuth, -): - response = await client.get(f"/{API_VTAG}/solvers/page", auth=auth) - assert response.status_code == status.HTTP_200_OK - assert len(response.json()["items"]) == response.json()["total"] - - -async def test_list_solver_releases_page_with_mocked_catalog( - client: httpx.AsyncClient, - mocked_catalog_rpc_api: dict, - auth: httpx.BasicAuth, -): - solver_key = "simcore/services/comp/itis/sleeper" - response = await client.get( - f"/{API_VTAG}/solvers/{solver_key}/releases/page", auth=auth - ) - assert response.status_code == status.HTTP_200_OK - assert len(response.json()["items"]) == response.json()["total"] - - -async def test_list_solver_releases_with_mocked_catalog( - client: httpx.AsyncClient, - mocked_catalog_rpc_api: dict, - auth: httpx.BasicAuth, -): - solver_key = "simcore/services/comp/itis/sleeper" - response = await client.get(f"/{API_VTAG}/solvers/{solver_key}/releases", auth=auth) - assert response.status_code == status.HTTP_200_OK - - -async def test_list_solver_ports_with_mocked_catalog( +async def test_list_solver_ports_again( + mocked_catalog_rpc_api: dict[str, MockType], client: httpx.AsyncClient, - mocked_catalog_rpc_api: dict, auth: httpx.BasicAuth, ): solver_key = "simcore/services/comp/itis/sleeper"