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 6f2eed5d27e..eb70605c9b3 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 @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from httpx import HTTPStatusError +from models_library.api_schemas_webserver.resource_usage import ServicePricingPlanGet from pydantic import ValidationError from pydantic.errors import PydanticValueError from servicelib.error_codes import create_error_code @@ -16,6 +17,7 @@ from ..dependencies.application import get_product_name, get_reverse_url_mapper from ..dependencies.authentication import get_current_user_id from ..dependencies.services import get_api_client +from ..dependencies.webserver import AuthSession, get_webserver_session from ._common import API_SERVER_DEV_FEATURES_ENABLED _logger = logging.getLogger(__name__) @@ -255,3 +257,20 @@ async def list_solver_ports( status_code=status.HTTP_404_NOT_FOUND, detail=f"Ports for solver {solver_key}:{version} not found", ) from err + + +@router.get( + "/{solver_key:path}/releases/{version}/pricing_plan", + response_model=ServicePricingPlanGet, + include_in_schema=API_SERVER_DEV_FEATURES_ENABLED, +) +async def get_solver_pricing_plan( + solver_key: SolverKeyId, + version: VersionStr, + user_id: Annotated[int, Depends(get_current_user_id)], + webserver_api: Annotated[AuthSession, Depends(get_webserver_session)], + product_name: Annotated[str, Depends(get_product_name)], +): + assert user_id + assert product_name + return await webserver_api.get_service_pricing_plan(solver_key, version) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py b/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py index 25fe5e94db3..9bdf56af55d 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py @@ -11,10 +11,12 @@ from fastapi.responses import RedirectResponse from fastapi_pagination.api import create_page from models_library.api_schemas_webserver.projects import ProjectCreateNew, ProjectGet +from models_library.api_schemas_webserver.resource_usage import PricingUnitGet from models_library.api_schemas_webserver.wallets import WalletGet from models_library.clusters import ClusterID from models_library.projects_nodes_io import BaseFileLink from pydantic.types import PositiveInt +from servicelib.logging_utils import log_context from ...db.repositories.groups_extra_properties import GroupsExtraPropertiesRepository from ...models.basic_types import VersionStr @@ -63,6 +65,19 @@ def _compose_job_resource_name(solver_key, solver_version, job_id) -> str: ) +def _raise_if_job_not_associated_with_solver( + solver_key: SolverKeyId, version: VersionStr, project: ProjectGet +) -> None: + expected_job_name: str = _compose_job_resource_name( + solver_key, version, project.uuid + ) + if expected_job_name != project.name: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"job {project.uuid} is not associated with solver {solver_key} and version {version}", + ) + + # JOBS --------------- # # - Similar to docker container's API design (container = job and image = solver) @@ -559,3 +574,28 @@ async def get_job_wallet( f"Cannot find job={job_name}", status_code=status.HTTP_404_NOT_FOUND, ) + + +@router.get( + "/{solver_key:path}/releases/{version}/jobs/{job_id:uuid}/pricing_unit", + response_model=PricingUnitGet | None, + responses={**_COMMON_ERROR_RESPONSES}, + include_in_schema=API_SERVER_DEV_FEATURES_ENABLED, +) +async def get_job_pricing_unit( + solver_key: SolverKeyId, + version: VersionStr, + job_id: JobID, + webserver_api: Annotated[AuthSession, Depends(get_webserver_session)], +): + job_name = _compose_job_resource_name(solver_key, version, job_id) + with log_context(_logger, logging.DEBUG, "Get pricing unit"): + _logger.debug("job: %s", job_name) + project: ProjectGet = await webserver_api.get_project(project_id=job_id) + _raise_if_job_not_associated_with_solver(solver_key, version, project) + node_ids = list(project.workbench.keys()) + assert len(node_ids) == 1 # nosec + node_id: UUID = UUID(node_ids[0]) + return await webserver_api.get_project_node_pricing_unit( + project_id=job_id, node_id=node_id + ) diff --git a/services/api-server/src/simcore_service_api_server/services/webserver.py b/services/api-server/src/simcore_service_api_server/services/webserver.py index 5860068ba5e..a7a118d07d0 100644 --- a/services/api-server/src/simcore_service_api_server/services/webserver.py +++ b/services/api-server/src/simcore_service_api_server/services/webserver.py @@ -15,6 +15,10 @@ ProjectMetadataGet, ProjectMetadataUpdate, ) +from models_library.api_schemas_webserver.resource_usage import ( + PricingUnitGet, + ServicePricingPlanGet, +) from models_library.api_schemas_webserver.wallets import WalletGet from models_library.generics import Envelope from models_library.projects import ProjectID @@ -24,6 +28,7 @@ from pydantic.errors import PydanticErrorMixin from servicelib.aiohttp.long_running_tasks.server import TaskStatus from servicelib.error_codes import create_error_code +from simcore_service_api_server.models.schemas.solvers import SolverKeyId from starlette import status from tenacity import TryAgain from tenacity._asyncio import AsyncRetrying @@ -32,6 +37,7 @@ from tenacity.wait import wait_fixed from ..core.settings import WebServerSettings +from ..models.basic_types import VersionStr from ..models.pagination import MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE from ..models.schemas.jobs import MetaValueType from ..models.types import AnyJson @@ -87,6 +93,11 @@ def _handle_webserver_api_errors(): msg = error.get("errors") or resp.reason_phrase or f"{exc}" raise HTTPException(resp.status_code, detail=msg) from exc + except ProjectNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + class WebserverApi(BaseServiceClientApi): """Access to web-server API @@ -284,16 +295,15 @@ async def clone_project(self, project_id: UUID) -> ProjectGet: return ProjectGet.parse_obj(result) async def get_project(self, project_id: UUID) -> ProjectGet: - response = await self.client.get( - f"/projects/{project_id}", - cookies=self.session_cookies, - ) - - data = self._get_data_or_raise( - response, - {status.HTTP_404_NOT_FOUND: ProjectNotFoundError(project_id=project_id)}, - ) - return ProjectGet.parse_obj(data) + with _handle_webserver_api_errors(): + response = await self.client.get( + f"/projects/{project_id}", + cookies=self.session_cookies, + ) + response.raise_for_status() + data = Envelope[ProjectGet].parse_raw(response.text).data + assert data is not None + return data async def get_projects_w_solver_page( self, solver_name: str, limit: int, offset: int @@ -369,14 +379,17 @@ async def update_project_metadata( assert data # nosec return data - async def get_project_wallet(self, project_id: ProjectID) -> WalletGet | None: + async def get_project_node_pricing_unit( + self, project_id: UUID, node_id: UUID + ) -> PricingUnitGet | None: with _handle_webserver_api_errors(): response = await self.client.get( - f"/projects/{project_id}/wallet", + f"/projects/{project_id}/nodes/{node_id}/pricing-unit", cookies=self.session_cookies, ) + response.raise_for_status() - data = Envelope[WalletGet].parse_raw(response.text).data + data = Envelope[PricingUnitGet].parse_raw(response.text).data return data # WALLETS ------------------------------------------------- @@ -392,6 +405,32 @@ async def get_wallet(self, wallet_id: int) -> WalletGet: assert data # nosec return data + async def get_project_wallet(self, project_id: ProjectID) -> WalletGet | None: + with _handle_webserver_api_errors(): + response = await self.client.get( + f"/projects/{project_id}/wallet", + cookies=self.session_cookies, + ) + response.raise_for_status() + data = Envelope[WalletGet].parse_raw(response.text).data + return data + + # SERVICES ------------------------------------------------- + + async def get_service_pricing_plan( + self, solver_key: SolverKeyId, version: VersionStr + ) -> ServicePricingPlanGet | None: + service_key = urllib.parse.quote_plus(solver_key) + + with _handle_webserver_api_errors(): + response = await self.client.get( + f"/catalog/services/{service_key}/{version}/pricing-plan", + cookies=self.session_cookies, + ) + response.raise_for_status() + data = Envelope[ServicePricingPlanGet].parse_raw(response.text).data + return data + # MODULES APP SETUP ------------------------------------------------------------- diff --git a/services/api-server/src/simcore_service_api_server/utils/http_calls_capture_processing.py b/services/api-server/src/simcore_service_api_server/utils/http_calls_capture_processing.py index f44945f56da..123e67c7c41 100644 --- a/services/api-server/src/simcore_service_api_server/utils/http_calls_capture_processing.py +++ b/services/api-server/src/simcore_service_api_server/utils/http_calls_capture_processing.py @@ -96,7 +96,7 @@ def regex_pattern(self) -> str: pattern = r"[+-]?\d+(?:\.\d+)?" elif self.type_ == "str": if self.format_ == "uuid": - pattern = r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}" + pattern = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-(3|4|5)[0-9a-fA-F]{3}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" else: pattern = r".*" # should match any string if pattern is None: diff --git a/services/api-server/tests/mocks/get_job_pricing_unit_invalid_job.json b/services/api-server/tests/mocks/get_job_pricing_unit_invalid_job.json new file mode 100644 index 00000000000..e6f79d165cd --- /dev/null +++ b/services/api-server/tests/mocks/get_job_pricing_unit_invalid_job.json @@ -0,0 +1,55 @@ +[ + { + "name": "GET /projects/87643648-3a38-44e2-9cfe-d86ab3d50620", + "description": "", + "method": "GET", + "host": "webserver", + "path": { + "path": "/v0/projects/{project_id}", + "path_parameters": [ + { + "in_": "path", + "name": "project_id", + "required": true, + "schema_": { + "title": "Project Id", + "type_": "str", + "pattern": null, + "format_": "uuid", + "exclusiveMinimum": null, + "minimum": null, + "anyOf": null, + "allOf": null, + "oneOf": null + }, + "response_value": "projects" + } + ] + }, + "query": null, + "request_payload": null, + "response_body": { + "data": null, + "error": { + "logs": [ + { + "message": "Project 87643648-3a38-44e2-9cfe-d86ab3d50620 not found", + "level": "ERROR", + "logger": "user" + } + ], + "errors": [ + { + "code": "HTTPNotFound", + "message": "Project 87643648-3a38-44e2-9cfe-d86ab3d50620 not found", + "resource": null, + "field": null + } + ], + "status": 404, + "message": "Project 87643648-3a38-44e2-9cfe-d86ab3d50620 not found" + } + }, + "status_code": 404 + } +] diff --git a/services/api-server/tests/mocks/get_job_pricing_unit_invalid_solver.json b/services/api-server/tests/mocks/get_job_pricing_unit_invalid_solver.json new file mode 100644 index 00000000000..c1ccb3755c7 --- /dev/null +++ b/services/api-server/tests/mocks/get_job_pricing_unit_invalid_solver.json @@ -0,0 +1,105 @@ +[ + { + "name": "GET /projects/87643648-3a38-44e2-9cfe-d86ab3d50629", + "description": "", + "method": "GET", + "host": "webserver", + "path": { + "path": "/v0/projects/{project_id}", + "path_parameters": [ + { + "in_": "path", + "name": "project_id", + "required": true, + "schema_": { + "title": "Project Id", + "type_": "str", + "pattern": null, + "format_": "uuid", + "exclusiveMinimum": null, + "minimum": null, + "anyOf": null, + "allOf": null, + "oneOf": null + }, + "response_value": "projects" + } + ] + }, + "query": null, + "request_payload": null, + "response_body": { + "data": { + "uuid": "87643648-3a38-44e2-9cfe-d86ab3d50629", + "name": "solvers/simcore%2Fservices%2Fcomp%2Fisolve/releases/2.1.24/jobs/87643648-3a38-44e2-9cfe-d86ab3d50629", + "description": "Study associated to solver job:\n{\n \"id\": \"87643648-3a38-44e2-9cfe-d86ab3d50629\",\n \"name\": \"solvers/simcore%2Fservices%2Fcomp%2Fisolve/releases/2.1.24/jobs/87643648-3a38-44e2-9cfe-d86ab3d50629\",\n \"inputs_checksum\": \"015ba4cd5cf00c511a8217deb65c242e3b15dc6ae4b1ecf94982d693887d9e8a\",\n \"created_at\": \"2023-10-10T20:15:22.071797+00:00\"\n}", + "thumbnail": "https://via.placeholder.com/170x120.png", + "creationDate": "2023-10-10T20:15:22.096Z", + "lastChangeDate": "2023-10-10T20:15:22.096Z", + "workbench": { + "4b03863d-107a-5c77-a3ca-c5ba1d7048c0": { + "key": "simcore/services/comp/isolve", + "version": "2.1.24", + "label": "isolve edge", + "progress": 0.0, + "inputs": { + "x": 4.33, + "n": 55, + "title": "Temperature", + "enabled": true, + "input_file": { + "store": 0, + "path": "api/0a3b2c56-dbcd-4871-b93b-d454b7883f9f/input.txt", + "label": "input.txt" + } + }, + "inputsUnits": {}, + "inputNodes": [], + "outputs": {}, + "state": { + "modified": true, + "dependencies": [], + "currentStatus": "NOT_STARTED", + "progress": null + } + } + }, + "prjOwner": "bisgaard@itis.swiss", + "accessRights": { + "3": { + "read": true, + "write": true, + "delete": true + } + }, + "tags": [], + "classifiers": [], + "state": { + "locked": { + "value": false, + "status": "CLOSED" + }, + "state": { + "value": "NOT_STARTED" + } + }, + "ui": { + "workbench": { + "4b03863d-107a-5c77-a3ca-c5ba1d7048c0": { + "position": { + "x": 633, + "y": 229 + } + } + }, + "slideshow": {}, + "currentNodeId": "4b03863d-107a-5c77-a3ca-c5ba1d7048c0", + "annotations": {} + }, + "quality": {}, + "dev": {} + } + }, + "status_code": 200 + } +] diff --git a/services/api-server/tests/mocks/get_job_pricing_unit_success.json b/services/api-server/tests/mocks/get_job_pricing_unit_success.json new file mode 100644 index 00000000000..6f1efe838fc --- /dev/null +++ b/services/api-server/tests/mocks/get_job_pricing_unit_success.json @@ -0,0 +1,162 @@ +[ + { + "name": "GET /projects/87643648-3a38-44e2-9cfe-d86ab3d50629", + "description": "", + "method": "GET", + "host": "webserver", + "path": { + "path": "/v0/projects/{project_id}", + "path_parameters": [ + { + "in_": "path", + "name": "project_id", + "required": true, + "schema_": { + "title": "Project Id", + "type_": "str", + "pattern": null, + "format_": "uuid", + "exclusiveMinimum": null, + "minimum": null, + "anyOf": null, + "allOf": null, + "oneOf": null + }, + "response_value": "projects" + } + ] + }, + "query": null, + "request_payload": null, + "response_body": { + "data": { + "uuid": "87643648-3a38-44e2-9cfe-d86ab3d50629", + "name": "solvers/simcore%2Fservices%2Fcomp%2Fisolve/releases/2.1.24/jobs/87643648-3a38-44e2-9cfe-d86ab3d50629", + "description": "Study associated to solver job:\n{\n \"id\": \"87643648-3a38-44e2-9cfe-d86ab3d50629\",\n \"name\": \"solvers/simcore%2Fservices%2Fcomp%2Fisolve/releases/2.1.24/jobs/87643648-3a38-44e2-9cfe-d86ab3d50629\",\n \"inputs_checksum\": \"015ba4cd5cf00c511a8217deb65c242e3b15dc6ae4b1ecf94982d693887d9e8a\",\n \"created_at\": \"2023-10-10T20:15:22.071797+00:00\"\n}", + "thumbnail": "https://via.placeholder.com/170x120.png", + "creationDate": "2023-10-10T20:15:22.096Z", + "lastChangeDate": "2023-10-10T20:15:22.096Z", + "workbench": { + "4b03863d-107a-5c77-a3ca-c5ba1d7048c0": { + "key": "simcore/services/comp/isolve", + "version": "2.1.24", + "label": "isolve edge", + "progress": 0.0, + "inputs": { + "x": 4.33, + "n": 55, + "title": "Temperature", + "enabled": true, + "input_file": { + "store": 0, + "path": "api/0a3b2c56-dbcd-4871-b93b-d454b7883f9f/input.txt", + "label": "input.txt" + } + }, + "inputsUnits": {}, + "inputNodes": [], + "outputs": {}, + "state": { + "modified": true, + "dependencies": [], + "currentStatus": "NOT_STARTED", + "progress": null + } + } + }, + "prjOwner": "bisgaard@itis.swiss", + "accessRights": { + "3": { + "read": true, + "write": true, + "delete": true + } + }, + "tags": [], + "classifiers": [], + "state": { + "locked": { + "value": false, + "status": "CLOSED" + }, + "state": { + "value": "NOT_STARTED" + } + }, + "ui": { + "workbench": { + "4b03863d-107a-5c77-a3ca-c5ba1d7048c0": { + "position": { + "x": 633, + "y": 229 + } + } + }, + "slideshow": {}, + "currentNodeId": "4b03863d-107a-5c77-a3ca-c5ba1d7048c0", + "annotations": {} + }, + "quality": {}, + "dev": {} + } + }, + "status_code": 200 + }, + { + "name": "GET /projects/87643648-3a38-44e2-9cfe-d86ab3d50629/nodes/4b03863d-107a-5c77-a3ca-c5ba1d7048c0/pricing-unit", + "description": "", + "method": "GET", + "host": "webserver", + "path": { + "path": "/v0/projects/{project_id}/nodes/{node_id}/pricing-unit", + "path_parameters": [ + { + "in_": "path", + "name": "node_id", + "required": true, + "schema_": { + "title": "Node Id", + "type_": "str", + "pattern": null, + "format_": "uuid", + "exclusiveMinimum": null, + "minimum": null, + "anyOf": null, + "allOf": null, + "oneOf": null + }, + "response_value": "nodes" + }, + { + "in_": "path", + "name": "project_id", + "required": true, + "schema_": { + "title": "Project Id", + "type_": "str", + "pattern": null, + "format_": "uuid", + "exclusiveMinimum": null, + "minimum": null, + "anyOf": null, + "allOf": null, + "oneOf": null + }, + "response_value": "projects" + } + ] + }, + "query": null, + "request_payload": null, + "response_body": { + "data": { + "pricingUnitId": 1, + "unitName": "small", + "unitExtraInfo": {}, + "currentCostPerUnit": 50, + "default": true + } + }, + "status_code": 200 + } +] diff --git a/services/api-server/tests/mocks/get_solver_pricing_plan_invalid_solver.json b/services/api-server/tests/mocks/get_solver_pricing_plan_invalid_solver.json new file mode 100644 index 00000000000..243ec729429 --- /dev/null +++ b/services/api-server/tests/mocks/get_solver_pricing_plan_invalid_solver.json @@ -0,0 +1,133 @@ +[ + { + "name": "GET /catalog/services/simcore%2Fservices%2Fcomp%2Fisolve/2.1.24/pricing-plan", + "description": "", + "method": "GET", + "host": "webserver", + "path": { + "path": "/v0/catalog/services/{service_key}/{service_version}/pricing-plan", + "path_parameters": [ + { + "in_": "path", + "name": "service_key", + "required": true, + "schema_": { + "title": "Service Key", + "type_": "str", + "pattern": "^simcore/services/((comp|dynamic|frontend))/([a-z0-9][a-z0-9_.-]*/)*([a-z0-9-_]+[a-z0-9])$", + "format_": null, + "exclusiveMinimum": null, + "minimum": null, + "anyOf": null, + "allOf": null, + "oneOf": null + }, + "response_value": "services" + }, + { + "in_": "path", + "name": "service_version", + "required": true, + "schema_": { + "title": "Service Version", + "type_": "str", + "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-]+)*)?$", + "format_": null, + "exclusiveMinimum": null, + "minimum": null, + "anyOf": null, + "allOf": null, + "oneOf": null + }, + "response_value": "simcore/services/comp/isolve" + } + ] + }, + "query": null, + "request_payload": null, + "response_body": { + "data": { + "pricingPlanId": 1, + "displayName": "osparc_pricing_plan", + "description": "", + "classification": "TIER", + "createdAt": "2023-10-12T07:16:31.155807+00:00", + "pricingPlanKey": "osparc_pricing_plan", + "pricingUnits": [ + { + "pricingUnitId": 1, + "unitName": "small", + "unitExtraInfo": {}, + "currentCostPerUnit": 50, + "default": true + } + ] + } + }, + "status_code": 200 + }, + { + "name": "GET /catalog/services/simcore%2Fservices%2Fcomp%2Fisolv/2.1.24/pricing-plan", + "description": "", + "method": "GET", + "host": "webserver", + "path": { + "path": "/v0/catalog/services/{service_key}/{service_version}/pricing-plan", + "path_parameters": [ + { + "in_": "path", + "name": "service_key", + "required": true, + "schema_": { + "title": "Service Key", + "type_": "str", + "pattern": "^simcore/services/((comp|dynamic|frontend))/([a-z0-9][a-z0-9_.-]*/)*([a-z0-9-_]+[a-z0-9])$", + "format_": null, + "exclusiveMinimum": null, + "minimum": null, + "anyOf": null, + "allOf": null, + "oneOf": null + }, + "response_value": "services" + }, + { + "in_": "path", + "name": "service_version", + "required": true, + "schema_": { + "title": "Service Version", + "type_": "str", + "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-]+)*)?$", + "format_": null, + "exclusiveMinimum": null, + "minimum": null, + "anyOf": null, + "allOf": null, + "oneOf": null + }, + "response_value": "simcore/services/comp/isolv" + } + ] + }, + "query": null, + "request_payload": null, + "response_body": { + "data": null, + "error": { + "logs": [], + "errors": [ + { + "code": "ValidationError", + "message": "7 validation errors for ParsingModel[ServicePricingPlanGet]\n__root__ -> pricing_plan_id\n field required (type=value_error.missing)\n__root__ -> display_name\n field required (type=value_error.missing)\n__root__ -> description\n field required (type=value_error.missing)\n__root__ -> classification\n field required (type=value_error.missing)\n__root__ -> created_at\n field required (type=value_error.missing)\n__root__ -> pricing_plan_key\n field required (type=value_error.missing)\n__root__ -> pricing_units\n field required (type=value_error.missing)", + "resource": null, + "field": null + } + ], + "status": 500, + "message": "Unexpected client error" + } + }, + "status_code": 500 + } +] diff --git a/services/api-server/tests/mocks/get_solver_pricing_plan_success.json b/services/api-server/tests/mocks/get_solver_pricing_plan_success.json new file mode 100644 index 00000000000..4b200ca09f8 --- /dev/null +++ b/services/api-server/tests/mocks/get_solver_pricing_plan_success.json @@ -0,0 +1,69 @@ +[ + { + "name": "GET /catalog/services/simcore%2Fservices%2Fcomp%2Fisolve/2.1.24/pricing-plan", + "description": "", + "method": "GET", + "host": "webserver", + "path": { + "path": "/v0/catalog/services/{service_key}/{service_version}/pricing-plan", + "path_parameters": [ + { + "in_": "path", + "name": "service_key", + "required": true, + "schema_": { + "title": "Service Key", + "type_": "str", + "pattern": "^simcore/services/((comp|dynamic|frontend))/([a-z0-9][a-z0-9_.-]*/)*([a-z0-9-_]+[a-z0-9])$", + "format_": null, + "exclusiveMinimum": null, + "minimum": null, + "anyOf": null, + "allOf": null, + "oneOf": null + }, + "response_value": "services" + }, + { + "in_": "path", + "name": "service_version", + "required": true, + "schema_": { + "title": "Service Version", + "type_": "str", + "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-]+)*)?$", + "format_": null, + "exclusiveMinimum": null, + "minimum": null, + "anyOf": null, + "allOf": null, + "oneOf": null + }, + "response_value": "simcore/services/comp/isolve" + } + ] + }, + "query": null, + "request_payload": null, + "response_body": { + "data": { + "pricingPlanId": 1, + "displayName": "osparc_pricing_plan", + "description": "", + "classification": "TIER", + "createdAt": "2023-10-12T07:16:31.155807+00:00", + "pricingPlanKey": "osparc_pricing_plan", + "pricingUnits": [ + { + "pricingUnitId": 1, + "unitName": "small", + "unitExtraInfo": {}, + "currentCostPerUnit": 50, + "default": true + } + ] + } + }, + "status_code": 200 + } +] diff --git a/services/api-server/tests/unit/api_studies/test_api_routes_studies.py b/services/api-server/tests/unit/api_studies/test_api_routes_studies.py index 7a10995d7b0..3cc92db680a 100644 --- a/services/api-server/tests/unit/api_studies/test_api_routes_studies.py +++ b/services/api-server/tests/unit/api_studies/test_api_routes_studies.py @@ -103,7 +103,7 @@ async def test_studies_read_workflow( resp = await client.get(f"/v0/studies/{inexistent_study_id}", auth=auth) assert resp.status_code == status.HTTP_404_NOT_FOUND error = parse_obj_as(ErrorGet, resp.json()) - assert f"{inexistent_study_id}" in error.errors[0] + assert f"{inexistent_study_id}" in error.errors[0][0]["message"] resp = await client.get(f"/v0/studies/{inexistent_study_id}/ports", auth=auth) assert resp.status_code == status.HTTP_404_NOT_FOUND diff --git a/services/api-server/tests/unit/conftest.py b/services/api-server/tests/unit/conftest.py index 908c40d3d5f..bdedf103824 100644 --- a/services/api-server/tests/unit/conftest.py +++ b/services/api-server/tests/unit/conftest.py @@ -499,30 +499,47 @@ def _generate_mock( if len(side_effects_callbacks) > 0: assert len(side_effects_callbacks) == len(captures) - def _side_effect(request: httpx.Request, **kwargs): - capture = next(capture_iter) - assert isinstance(capture.path, PathDescription) - status_code: int = capture.status_code - response_body: dict[str, Any] | list | None = capture.response_body - assert {param.name for param in capture.path.path_parameters} == set( - kwargs.keys() - ) - if len(side_effects_callbacks) > 0: - callback = next(side_effect_callback_iter) - response_body = callback(request, kwargs, capture) - return httpx.Response(status_code=status_code, json=response_body) + class CaptureSideEffect: + def __init__( + self, + capture: HttpApiCallCaptureModel, + side_effect: SideEffectCallback | None, + ): + self._capture = capture + self._side_effect_callback = side_effect + + def _side_effect(self, request: httpx.Request, **kwargs): + capture = self._capture + assert isinstance(capture.path, PathDescription) + status_code: int = capture.status_code + response_body: dict[str, Any] | list | None = capture.response_body + assert {param.name for param in capture.path.path_parameters} == set( + kwargs.keys() + ) + if self._side_effect_callback: + response_body = self._side_effect_callback(request, kwargs, capture) + return httpx.Response(status_code=status_code, json=response_body) - for capture in captures: + side_effects: list[CaptureSideEffect] = [] + for ii, capture in enumerate(captures): url_path: PathDescription | str = capture.path assert isinstance(url_path, PathDescription) path_regex: str = str(url_path.path) + side_effects.append( + CaptureSideEffect( + capture=capture, + side_effect=side_effects_callbacks[ii] + if len(side_effects_callbacks) + else None, + ) + ) for param in url_path.path_parameters: path_regex = path_regex.replace( "{" + param.name + "}", param.respx_lookup ) respx_mock.request( - capture.method.upper(), url=None, path__regex=path_regex - ).mock(side_effect=_side_effect) + capture.method.upper(), url=None, path__regex="^" + path_regex + "$" + ).mock(side_effect=side_effects[-1]._side_effect) return respx_mock diff --git a/services/api-server/tests/unit/test_api_solver_jobs.py b/services/api-server/tests/unit/test_api_solver_jobs.py index 1177af17d0c..962f8131092 100644 --- a/services/api-server/tests/unit/test_api_solver_jobs.py +++ b/services/api-server/tests/unit/test_api_solver_jobs.py @@ -6,7 +6,11 @@ import pytest import respx from httpx import AsyncClient +from models_library.api_schemas_webserver.resource_usage import PricingUnitGet +from pydantic import parse_obj_as from simcore_service_api_server._meta import API_VTAG +from simcore_service_api_server.models.schemas.jobs import Job +from simcore_service_api_server.models.schemas.solvers import Solver from simcore_service_api_server.utils.http_calls_capture import HttpApiCallCaptureModel from unit.conftest import SideEffectCallback @@ -69,3 +73,76 @@ def _get_job_wallet_side_effect( assert body.get("errors") is not None else: pytest.fail() + + +@pytest.mark.parametrize( + "capture_file", + [ + "get_job_pricing_unit_invalid_job.json", + "get_job_pricing_unit_invalid_solver.json", + "get_job_pricing_unit_success.json", + ], +) +async def test_get_solver_job_pricing_unit( + client: AsyncClient, + mocked_webserver_service_api_base, + respx_mock_from_capture: Callable[ + [respx.MockRouter, Path, list[SideEffectCallback] | None], respx.MockRouter + ], + auth: httpx.BasicAuth, + project_tests_dir: Path, + capture_file: str, +): + + solver_key: str = "simcore/services/comp/my_super_hpc_solver" + solver_version: str = "3.14.0" + job_id: UUID = UUID("87643648-3a38-44e2-9cfe-d86ab3d50629") + + def _get_job_side_effect( + request: httpx.Request, + path_params: dict[str, Any], + capture: HttpApiCallCaptureModel, + ) -> Any: + response = capture.response_body + assert isinstance(response, dict) + if data := response.get("data"): + assert isinstance(data, dict) + assert data.get("uuid") + data["uuid"] = path_params["project_id"] + assert data.get("name") + if capture_file != "get_job_pricing_unit_invalid_solver.json": + data["name"] = Job.compose_resource_name( + parent_name=Solver.compose_resource_name(solver_key, solver_version), # type: ignore + job_id=job_id, + ) + response["data"] = data + return response + + def _get_pricing_unit_side_effect( + request: httpx.Request, + path_params: dict[str, Any], + capture: HttpApiCallCaptureModel, + ) -> Any: + return capture.response_body + + respx_mock = respx_mock_from_capture( + mocked_webserver_service_api_base, + project_tests_dir / "mocks" / capture_file, + [_get_job_side_effect, _get_pricing_unit_side_effect] + if capture_file == "get_job_pricing_unit_success.json" + else [_get_job_side_effect], + ) + + response = await client.get( + f"{API_VTAG}/solvers/{solver_key}/releases/{solver_version}/jobs/{job_id}/pricing_unit", + auth=auth, + ) + if capture_file == "get_job_pricing_unit_success.json": + assert response.status_code == 200 + _ = parse_obj_as(PricingUnitGet, response.json()) + elif capture_file == "get_job_pricing_unit_invalid_job.json": + assert response.status_code == 404 + elif capture_file == "get_job_pricing_unit_invalid_solver.json": + assert response.status_code == 422 + else: + pytest.fail() diff --git a/services/api-server/tests/unit/test_api_solvers.py b/services/api-server/tests/unit/test_api_solvers.py new file mode 100644 index 00000000000..6a0e03dfb92 --- /dev/null +++ b/services/api-server/tests/unit/test_api_solvers.py @@ -0,0 +1,46 @@ +from pathlib import Path +from typing import Callable + +import httpx +import pytest +import respx +from httpx import AsyncClient +from models_library.api_schemas_webserver.resource_usage import ServicePricingPlanGet +from pydantic import parse_obj_as +from simcore_service_api_server._meta import API_VTAG +from unit.conftest import SideEffectCallback + + +@pytest.mark.parametrize( + "capture,expected_status_code", + [ + ("get_solver_pricing_plan_invalid_solver.json", 503), + ("get_solver_pricing_plan_success.json", 200), + ], +) +async def test_get_solver_pricing_plan( + client: AsyncClient, + mocked_webserver_service_api_base, + respx_mock_from_capture: Callable[ + [respx.MockRouter, Path, list[SideEffectCallback] | None], respx.MockRouter + ], + auth: httpx.BasicAuth, + project_tests_dir: Path, + capture: str, + expected_status_code: int, +): + + respx_mock = respx_mock_from_capture( + mocked_webserver_service_api_base, project_tests_dir / "mocks" / capture, None + ) + assert respx_mock + + _my_solver: str = "simcore/services/comp/my_solver" + _version: str = "2.4.3" + response = await client.get( + f"{API_VTAG}/solvers/{_my_solver}/releases/{_version}/pricing_plan", + auth=auth, + ) + assert expected_status_code == response.status_code + if response.status_code == 200: + _ = parse_obj_as(ServicePricingPlanGet, response.json())