diff --git a/api/specs/web-server/_projects.py b/api/specs/web-server/_projects.py index 315739d0dfcd..4c5a96c31989 100644 --- a/api/specs/web-server/_projects.py +++ b/api/specs/web-server/_projects.py @@ -27,9 +27,13 @@ from models_library.generics import Envelope from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID +from models_library.rest_error import EnvelopedError from models_library.rest_pagination import Page from pydantic import BaseModel from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.projects._common.exceptions_handlers import ( + _TO_HTTP_ERROR_MAP, +) from simcore_service_webserver.projects._common.models import ProjectPathParams from simcore_service_webserver.projects._crud_handlers import ProjectCreateQueryParams from simcore_service_webserver.projects._crud_handlers_models import ( @@ -43,6 +47,9 @@ tags=[ "projects", ], + responses={ + i.status_code: {"model": EnvelopedError} for i in _TO_HTTP_ERROR_MAP.values() + }, ) diff --git a/api/specs/web-server/_trash.py b/api/specs/web-server/_trash.py index 203c05a4c9e7..193d994d55cd 100644 --- a/api/specs/web-server/_trash.py +++ b/api/specs/web-server/_trash.py @@ -8,12 +8,16 @@ from typing import Annotated from fastapi import APIRouter, Depends, status +from models_library.rest_error import EnvelopedError from models_library.trash import RemoveQueryParams from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.folders._common.models import ( FoldersPathParams, FolderTrashQueryParams, ) +from simcore_service_webserver.projects._common.exceptions_handlers import ( + _TO_HTTP_ERROR_MAP, +) from simcore_service_webserver.projects._trash_rest import ProjectPathParams from simcore_service_webserver.workspaces._common.models import ( WorkspacesPathParams, @@ -23,6 +27,9 @@ router = APIRouter( prefix=f"/{API_VTAG}", tags=["trash"], + responses={ + i.status_code: {"model": EnvelopedError} for i in _TO_HTTP_ERROR_MAP.values() + }, ) @@ -42,11 +49,18 @@ def empty_trash(): tags=_extra_tags, status_code=status.HTTP_204_NO_CONTENT, responses={ - status.HTTP_404_NOT_FOUND: {"description": "Not such a project"}, + status.HTTP_404_NOT_FOUND: { + "description": "Not such a project", + "model": EnvelopedError, + }, status.HTTP_409_CONFLICT: { - "description": "Project is in use and cannot be trashed" + "description": "Project is in use and cannot be trashed", + "model": EnvelopedError, + }, + status.HTTP_503_SERVICE_UNAVAILABLE: { + "description": "Trash service error", + "model": EnvelopedError, }, - status.HTTP_503_SERVICE_UNAVAILABLE: {"description": "Trash service error"}, }, ) def trash_project( diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index ffb889df5280..584614e6050b 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -3656,6 +3656,48 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_TaskGet_' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable get: tags: - projects @@ -3743,6 +3785,48 @@ paths: application/json: schema: $ref: '#/components/schemas/Page_ProjectListItem_' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable /v0/projects/active: get: tags: @@ -3763,6 +3847,48 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_ProjectGet_' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable /v0/projects/{project_id}: get: tags: @@ -3784,6 +3910,48 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_ProjectGet_' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable patch: tags: - projects @@ -3806,6 +3974,48 @@ paths: responses: '204': description: Successful Response + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable delete: tags: - projects @@ -3822,6 +4032,48 @@ paths: responses: '204': description: Successful Response + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable /v0/projects/{project_id}:clone: post: tags: @@ -3843,6 +4095,48 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_TaskGet_' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable /v0/projects:search: get: tags: @@ -3906,6 +4200,48 @@ paths: application/json: schema: $ref: '#/components/schemas/Page_ProjectListItem_' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable /v0/projects/{project_id}/inactivity: get: tags: @@ -3926,7 +4262,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_GetProjectInactivityResponse_' + $ref: '#/components/schemas/Envelope_GetProjectInactivityResponse_' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable /v0/projects/{project_uuid}/comments: post: tags: @@ -5982,6 +6360,48 @@ paths: responses: '204': description: Successful Response + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + '422': + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + '402': + description: Payment Required + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + '503': + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' /v0/projects/{project_id}:trash: post: tags: @@ -6007,12 +6427,48 @@ paths: responses: '204': description: Successful Response + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden '404': description: Not such a project + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' '409': description: Project is in use and cannot be trashed + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required '503': description: Trash service error + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' /v0/projects/{project_id}:untrash: post: tags: @@ -6031,6 +6487,48 @@ paths: responses: '204': description: Successful Response + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable /v0/folders/{folder_id}:trash: post: tags: @@ -6057,11 +6555,35 @@ paths: responses: '204': description: Successful Response + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden '404': description: Not such a folder '409': description: One or more projects in the folder are in use and cannot be trashed + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required '503': description: Trash service error /v0/folders/{folder_id}:untrash: @@ -6083,6 +6605,48 @@ paths: responses: '204': description: Successful Response + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable /v0/workspaces/{workspace_id}:trash: post: tags: @@ -6109,11 +6673,35 @@ paths: responses: '204': description: Successful Response + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden '404': description: Not such a workspace '409': description: One or more projects in the workspace are in use and cannot be trashed + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required '503': description: Trash service error /v0/workspaces/{workspace_id}:untrash: @@ -6135,6 +6723,48 @@ paths: responses: '204': description: Successful Response + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Unprocessable Entity + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable /v0/workspaces: post: tags: diff --git a/services/web/server/src/simcore_service_webserver/projects/_comments_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_comments_handlers.py index 04ac3d5ca35a..f3c274d13b90 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_comments_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_comments_handlers.py @@ -2,7 +2,6 @@ """ -import functools import logging from typing import Any @@ -22,7 +21,6 @@ parse_request_path_parameters_as, parse_request_query_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from servicelib.rest_constants import RESPONSE_MODEL_POLICY @@ -31,26 +29,11 @@ from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _comments_api, projects_service +from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import RequestContext -from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError _logger = logging.getLogger(__name__) - -def _handle_project_comments_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except ProjectNotFoundError as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - except ProjectInvalidRightsError as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - return wrapper - - # # projects/*/comments COLLECTION ------------------------- # @@ -79,7 +62,7 @@ class _ProjectCommentsBodyParams(BaseModel): ) @login_required @permission_required("project.read") -@_handle_project_comments_exceptions +@handle_plugin_requests_exceptions async def create_project_comment(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(_ProjectCommentsPathParams, request) @@ -119,7 +102,7 @@ class _ListProjectCommentsQueryParams(BaseModel): @routes.get(f"/{VTAG}/projects/{{project_uuid}}/comments", name="list_project_comments") @login_required @permission_required("project.read") -@_handle_project_comments_exceptions +@handle_plugin_requests_exceptions async def list_project_comments(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(_ProjectCommentsPathParams, request) @@ -168,6 +151,7 @@ async def list_project_comments(request: web.Request): ) @login_required @permission_required("project.read") +@handle_plugin_requests_exceptions async def update_project_comment(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as( @@ -183,12 +167,13 @@ async def update_project_comment(request: web.Request): include_state=False, ) - return await _comments_api.update_project_comment( + updated_comment = await _comments_api.update_project_comment( request=request, comment_id=path_params.comment_id, project_uuid=path_params.project_uuid, contents=body_params.contents, ) + return envelope_json_response(updated_comment) @routes.delete( @@ -197,7 +182,7 @@ async def update_project_comment(request: web.Request): ) @login_required @permission_required("project.read") -@_handle_project_comments_exceptions +@handle_plugin_requests_exceptions async def delete_project_comment(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as( @@ -225,7 +210,7 @@ async def delete_project_comment(request: web.Request): ) @login_required @permission_required("project.read") -@_handle_project_comments_exceptions +@handle_plugin_requests_exceptions async def get_project_comment(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as( @@ -240,7 +225,8 @@ async def get_project_comment(request: web.Request): include_state=False, ) - return await _comments_api.get_project_comment( + comment = await _comments_api.get_project_comment( request=request, comment_id=path_params.comment_id, ) + return envelope_json_response(comment) diff --git a/services/web/server/src/simcore_service_webserver/projects/_common/exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_common/exceptions_handlers.py new file mode 100644 index 000000000000..14963f6ca534 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/projects/_common/exceptions_handlers.py @@ -0,0 +1,189 @@ +import logging + +from servicelib.aiohttp import status +from servicelib.rabbitmq.rpc_interfaces.catalog.errors import ( + CatalogForbiddenError, + CatalogItemNotFoundError, +) + +from ...exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from ...folders.errors import FolderAccessForbiddenError, FolderNotFoundError +from ...resource_usage.errors import DefaultPricingPlanNotFoundError +from ...users.exceptions import UserDefaultWalletNotFoundError +from ...wallets.errors import WalletAccessForbiddenError, WalletNotEnoughCreditsError +from ...workspaces.errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError +from ..exceptions import ( + ClustersKeeperNotAvailableError, + DefaultPricingUnitNotFoundError, + NodeNotFoundError, + ParentNodeNotFoundError, + ProjectDeleteError, + ProjectGroupNotFoundError, + ProjectInDebtCanNotChangeWalletError, + ProjectInDebtCanNotOpenError, + ProjectInvalidRightsError, + ProjectInvalidUsageError, + ProjectNodeRequiredInputsNotSetError, + ProjectNotFoundError, + ProjectOwnerNotFoundInTheProjectAccessRightsError, + ProjectStartsTooManyDynamicNodesError, + ProjectTooManyProjectOpenedError, + ProjectWalletPendingTransactionError, + WrongTagIdsInQueryError, +) + +_logger = logging.getLogger(__name__) + + +_FOLDER_ERRORS: ExceptionToHttpErrorMap = { + FolderAccessForbiddenError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Access to folder forbidden", + ), + FolderNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Folder not found: {reason}", + ), +} + + +_NODE_ERRORS: ExceptionToHttpErrorMap = { + NodeNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Node '{node_uuid}' not found in project '{project_uuid}'", + ), + ParentNodeNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Parent node '{node_uuid}' not found", + ), + ProjectNodeRequiredInputsNotSetError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "Project node is required but input is not set", + ), +} + + +_PROJECT_ERRORS: ExceptionToHttpErrorMap = { + ProjectDeleteError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "Failed to complete deletion of '{project_uuid}': {reason}", + ), + ProjectGroupNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Project group not found: {reason}", + ), + ProjectInvalidRightsError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Do not have sufficient access rights on project {project_uuid} for this action", + ), + ProjectInvalidUsageError: HttpErrorInfo( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "Invalid usage for project", + ), + ProjectNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Project {project_uuid} not found", + ), + ProjectOwnerNotFoundInTheProjectAccessRightsError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + "Project owner identifier was not found in the project's access-rights field", + ), + ProjectTooManyProjectOpenedError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "You cannot open more than {max_num_projects} study/ies at once. Please close another study and retry.", + ), + ProjectStartsTooManyDynamicNodesError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "The maximal amount of concurrently running dynamic services was reached. Please manually stop a service and retry.", + ), + ProjectWalletPendingTransactionError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "Project has currently pending transactions. It is forbidden to change wallet.", + ), + ProjectInDebtCanNotChangeWalletError: HttpErrorInfo( + status.HTTP_402_PAYMENT_REQUIRED, + "Unable to change the credit account linked to the project. The project is embargoed because the last transaction of {debt_amount} resulted in the credit account going negative.", + ), + ProjectInDebtCanNotOpenError: HttpErrorInfo( + status.HTTP_402_PAYMENT_REQUIRED, + "Unable to open the project. The project is embargoed because the last transaction of {debt_amount} resulted in the credit account going negative.", + ), + WrongTagIdsInQueryError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + "Wrong tag IDs in query", + ), +} + + +_WORKSPACE_ERRORS: ExceptionToHttpErrorMap = { + WorkspaceAccessForbiddenError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Access to workspace forbidden: {reason}", + ), + WorkspaceNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Workspace not found: {reason}", + ), +} + + +_WALLET_ERRORS: ExceptionToHttpErrorMap = { + UserDefaultWalletNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Wallet not found: {reason}", + ), + WalletAccessForbiddenError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Payment required, but the user lacks access to the project's linked wallet: Wallet access forbidden. {reason}", + ), + WalletNotEnoughCreditsError: HttpErrorInfo( + status.HTTP_402_PAYMENT_REQUIRED, + "Wallet does not have enough credits. {reason}", + ), +} + + +_PRICING_ERRORS: ExceptionToHttpErrorMap = { + DefaultPricingPlanNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Default pricing plan not found", + ), + DefaultPricingUnitNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Default pricing unit not found", + ), +} + + +_OTHER_ERRORS: ExceptionToHttpErrorMap = { + ClustersKeeperNotAvailableError: HttpErrorInfo( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Clusters-keeper service is not available", + ), + CatalogForbiddenError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Catalog forbidden: Insufficient access rights for {name}", + ), + CatalogItemNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, "{name} was not found" + ), +} + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + **_FOLDER_ERRORS, + **_NODE_ERRORS, + **_OTHER_ERRORS, + **_PRICING_ERRORS, + **_PROJECT_ERRORS, + **_WALLET_ERRORS, + **_WORKSPACE_ERRORS, +} + +handle_plugin_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py index 2abadb0982b5..24a51ea4142c 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py @@ -5,7 +5,8 @@ """ -from typing import Any, Coroutine +from collections.abc import Coroutine +from typing import Any from aiohttp import web from models_library.folders import FolderID, FolderQuery, FolderScope @@ -22,7 +23,7 @@ ) from ..catalog.client import get_services_for_user_in_product -from ..folders import _folders_repository as _folders_repository +from ..folders import _folders_repository from ..workspaces._workspaces_service import check_user_workspace_access from . import projects_service from .db import ProjectDBAPI diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py index 72cbe07d9718..f121be0a0eea 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py @@ -4,7 +4,6 @@ """ -import functools import logging from aiohttp import web @@ -28,25 +27,24 @@ parse_request_path_parameters_as, parse_request_query_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler from servicelib.common_headers import ( UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE, X_SIMCORE_USER_AGENT, ) from servicelib.redis import get_project_locked_state from simcore_service_webserver.projects.models import ProjectDict +from simcore_service_webserver.utils_aiohttp import envelope_json_response from .._meta import API_VTAG as VTAG from ..catalog.client import get_services_for_user_in_product -from ..folders.errors import FolderAccessForbiddenError, FolderNotFoundError from ..login.decorators import login_required from ..redis import get_redis_lock_manager_client_sdk from ..resource_manager.user_sessions import PROJECT_ID_KEY, managed_resource from ..security.api import check_user_permission from ..security.decorators import permission_required from ..users.api import get_user_fullname -from ..workspaces.errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError from . import _crud_api_create, _crud_api_read, _crud_handlers_utils, projects_service +from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ProjectPathParams, RequestContext from ._crud_handlers_models import ( ProjectActiveQueryParams, @@ -57,13 +55,6 @@ ProjectsSearchQueryParams, ) from ._permalink_service import update_or_pop_permalink_in_project -from .exceptions import ( - ProjectDeleteError, - ProjectInvalidRightsError, - ProjectNotFoundError, - ProjectOwnerNotFoundInTheProjectAccessRightsError, - WrongTagIdsInQueryError, -) from .utils import get_project_unavailable_services, project_uses_available_services # When the user requests a project with a repo, the working copy might differ from @@ -72,37 +63,9 @@ # response needs to refer to the uuid of the request and this is passed through this request key RQ_REQUESTED_REPO_PROJECT_UUID_KEY = f"{__name__}.RQT_REQUESTED_REPO_PROJECT_UUID_KEY" - _logger = logging.getLogger(__name__) -def _handle_projects_exceptions(handler: Handler): - @functools.wraps(handler) - async def _wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except ( - ProjectNotFoundError, - FolderNotFoundError, - WorkspaceNotFoundError, - ) as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - except ( - ProjectOwnerNotFoundInTheProjectAccessRightsError, - WrongTagIdsInQueryError, - ) as exc: - raise web.HTTPBadRequest(reason=f"{exc}") from exc - except ( - ProjectInvalidRightsError, - FolderAccessForbiddenError, - WorkspaceAccessForbiddenError, - ) as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - return _wrapper - - routes = web.RouteTableDef() @@ -110,7 +73,7 @@ async def _wrapper(request: web.Request) -> web.StreamResponse: @login_required @permission_required("project.create") @permission_required("services.pipeline.*") # due to update_pipeline_db -@_handle_projects_exceptions +@handle_plugin_requests_exceptions async def create_project(request: web.Request): # # - Create https://google.aip.dev/133 @@ -165,7 +128,7 @@ async def create_project(request: web.Request): @routes.get(f"/{VTAG}/projects", name="list_projects") @login_required @permission_required("project.read") -@_handle_projects_exceptions +@handle_plugin_requests_exceptions async def list_projects(request: web.Request): # # - List https://google.aip.dev/132 @@ -218,7 +181,7 @@ async def list_projects(request: web.Request): @routes.get(f"/{VTAG}/projects:search", name="list_projects_full_search") @login_required @permission_required("project.read") -@_handle_projects_exceptions +@handle_plugin_requests_exceptions async def list_projects_full_search(request: web.Request): req_ctx = RequestContext.model_validate(request) query_params: ProjectsSearchQueryParams = parse_request_query_parameters_as( @@ -258,7 +221,7 @@ async def list_projects_full_search(request: web.Request): @routes.get(f"/{VTAG}/projects/active", name="get_active_project") @login_required @permission_required("project.read") -@_handle_projects_exceptions +@handle_plugin_requests_exceptions async def get_active_project(request: web.Request) -> web.Response: # # - Get https://google.aip.dev/131 @@ -275,39 +238,35 @@ async def get_active_project(request: web.Request) -> web.Response: ProjectActiveQueryParams, request ) - try: - user_active_projects = [] - with managed_resource( - req_ctx.user_id, query_params.client_session_id, request.app - ) as rt: - # get user's projects - user_active_projects = await rt.find(PROJECT_ID_KEY) - - data = None - if user_active_projects: - project = await projects_service.get_project_for_user( - request.app, - project_uuid=user_active_projects[0], - user_id=req_ctx.user_id, - include_state=True, - include_trashed_by_primary_gid=True, - ) + user_active_projects = [] + with managed_resource( + req_ctx.user_id, query_params.client_session_id, request.app + ) as rt: + # get user's projects + user_active_projects = await rt.find(PROJECT_ID_KEY) - # updates project's permalink field - await update_or_pop_permalink_in_project(request, project) + data = None + if user_active_projects: + project = await projects_service.get_project_for_user( + request.app, + project_uuid=user_active_projects[0], + user_id=req_ctx.user_id, + include_state=True, + include_trashed_by_primary_gid=True, + ) - data = ProjectGet.from_domain_model(project).data(exclude_unset=True) + # updates project's permalink field + await update_or_pop_permalink_in_project(request, project) - return web.json_response({"data": data}, dumps=json_dumps) + data = ProjectGet.from_domain_model(project).data(exclude_unset=True) - except ProjectNotFoundError as exc: - raise web.HTTPNotFound(reason="Project not found") from exc + return envelope_json_response(data) @routes.get(f"/{VTAG}/projects/{{project_id}}", name="get_project") @login_required @permission_required("project.read") -@_handle_projects_exceptions +@handle_plugin_requests_exceptions async def get_project(request: web.Request): """ @@ -325,46 +284,36 @@ async def get_project(request: web.Request): request.app, req_ctx.user_id, req_ctx.product_name, only_key_versions=True ) - try: - project = await projects_service.get_project_for_user( - request.app, - project_uuid=f"{path_params.project_id}", - user_id=req_ctx.user_id, - include_state=True, - include_trashed_by_primary_gid=True, + project = await projects_service.get_project_for_user( + request.app, + project_uuid=f"{path_params.project_id}", + user_id=req_ctx.user_id, + include_state=True, + include_trashed_by_primary_gid=True, + ) + if not await project_uses_available_services(project, user_available_services): + unavilable_services = get_project_unavailable_services( + project, user_available_services ) - if not await project_uses_available_services(project, user_available_services): - unavilable_services = get_project_unavailable_services( - project, user_available_services - ) - formatted_services = ", ".join( - f"{service}:{version}" for service, version in unavilable_services - ) - # TODO: lack of permissions should be notified with https://httpstatuses.com/403 web.HTTPForbidden - raise web.HTTPNotFound( - reason=( - f"Project '{path_params.project_id}' uses unavailable services. Please ask " - f"for permission for the following services {formatted_services}" - ) + formatted_services = ", ".join( + f"{service}:{version}" for service, version in unavilable_services + ) + # TODO: lack of permissions should be notified with https://httpstatuses.com/403 web.HTTPForbidden + raise web.HTTPNotFound( + reason=( + f"Project '{path_params.project_id}' uses unavailable services. Please ask " + f"for permission for the following services {formatted_services}" ) + ) - if new_uuid := request.get(RQ_REQUESTED_REPO_PROJECT_UUID_KEY): - project["uuid"] = new_uuid - - # Adds permalink - await update_or_pop_permalink_in_project(request, project) + if new_uuid := request.get(RQ_REQUESTED_REPO_PROJECT_UUID_KEY): + project["uuid"] = new_uuid - data = ProjectGet.from_domain_model(project).data(exclude_unset=True) - return web.json_response({"data": data}, dumps=json_dumps) + # Adds permalink + await update_or_pop_permalink_in_project(request, project) - except ProjectInvalidRightsError as exc: - raise web.HTTPForbidden( - reason=f"You do not have sufficient rights to read project {path_params.project_id}" - ) from exc - except ProjectNotFoundError as exc: - raise web.HTTPNotFound( - reason=f"Project {path_params.project_id} not found" - ) from exc + data = ProjectGet.from_domain_model(project).data(exclude_unset=True) + return envelope_json_response(data) @routes.get( @@ -372,7 +321,7 @@ async def get_project(request: web.Request): ) @login_required @permission_required("project.read") -@_handle_projects_exceptions +@handle_plugin_requests_exceptions async def get_project_inactivity(request: web.Request): path_params = parse_request_path_parameters_as(ProjectPathParams, request) @@ -386,7 +335,7 @@ async def get_project_inactivity(request: web.Request): @login_required @permission_required("project.update") @permission_required("services.pipeline.*") -@_handle_projects_exceptions +@handle_plugin_requests_exceptions async def patch_project(request: web.Request): # # Update https://google.aip.dev/134 @@ -409,7 +358,7 @@ async def patch_project(request: web.Request): @routes.delete(f"/{VTAG}/projects/{{project_id}}", name="delete_project") @login_required @permission_required("project.delete") -@_handle_projects_exceptions +@handle_plugin_requests_exceptions async def delete_project(request: web.Request): # Delete https://google.aip.dev/135 """ @@ -427,64 +376,52 @@ async def delete_project(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) - try: - await projects_service.get_project_for_user( - request.app, - project_uuid=f"{path_params.project_id}", - user_id=req_ctx.user_id, - ) - project_users: set[int] = set() - with managed_resource(req_ctx.user_id, None, request.app) as user_session: - project_users = { - s.user_id - for s in await user_session.find_users_of_resource( - request.app, PROJECT_ID_KEY, f"{path_params.project_id}" - ) - } - # that project is still in use - if req_ctx.user_id in project_users: - raise web.HTTPForbidden( - reason="Project is still open in another tab/browser." - "It cannot be deleted until it is closed." - ) - if project_users: - other_user_names = { - f"{await get_user_fullname(request.app, user_id=uid)}" - for uid in project_users - } - raise web.HTTPForbidden( - reason=f"Project is open by {other_user_names}. " - "It cannot be deleted until the project is closed." - ) - - project_locked_state: ProjectLocked | None - if project_locked_state := await get_project_locked_state( - get_redis_lock_manager_client_sdk(request.app), - project_uuid=path_params.project_id, - ): - raise web.HTTPConflict( - reason=f"Project {path_params.project_id} is locked: {project_locked_state=}" + await projects_service.get_project_for_user( + request.app, + project_uuid=f"{path_params.project_id}", + user_id=req_ctx.user_id, + ) + project_users: set[int] = set() + with managed_resource(req_ctx.user_id, None, request.app) as user_session: + project_users = { + s.user_id + for s in await user_session.find_users_of_resource( + request.app, PROJECT_ID_KEY, f"{path_params.project_id}" ) + } + # that project is still in use + if req_ctx.user_id in project_users: + raise web.HTTPForbidden( + reason="Project is still open in another tab/browser." + "It cannot be deleted until it is closed." + ) + if project_users: + other_user_names = { + f"{await get_user_fullname(request.app, user_id=uid)}" + for uid in project_users + } + raise web.HTTPForbidden( + reason=f"Project is open by {other_user_names}. " + "It cannot be deleted until the project is closed." + ) - await projects_service.submit_delete_project_task( - request.app, - project_uuid=path_params.project_id, - user_id=req_ctx.user_id, - simcore_user_agent=request.headers.get( - X_SIMCORE_USER_AGENT, UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE - ), + project_locked_state: ProjectLocked | None + if project_locked_state := await get_project_locked_state( + get_redis_lock_manager_client_sdk(request.app), + project_uuid=path_params.project_id, + ): + raise web.HTTPConflict( + reason=f"Project {path_params.project_id} is locked: {project_locked_state=}" ) - except ProjectInvalidRightsError as err: - raise web.HTTPForbidden( - reason="You do not have sufficient rights to delete this project" - ) from err - except ProjectNotFoundError as err: - raise web.HTTPNotFound( - reason=f"Project {path_params.project_id} not found" - ) from err - except ProjectDeleteError as err: - raise web.HTTPConflict(reason=f"{err}") from err + await projects_service.submit_delete_project_task( + request.app, + project_uuid=path_params.project_id, + user_id=req_ctx.user_id, + simcore_user_agent=request.headers.get( + X_SIMCORE_USER_AGENT, UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE + ), + ) return web.json_response(status=status.HTTP_204_NO_CONTENT) @@ -500,7 +437,7 @@ async def delete_project(request: web.Request): @login_required @permission_required("project.create") @permission_required("services.pipeline.*") # due to update_pipeline_db -@_handle_projects_exceptions +@handle_plugin_requests_exceptions async def clone_project(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) diff --git a/services/web/server/src/simcore_service_webserver/projects/_folders_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_folders_handlers.py index c4f1828237b8..7a213d0a1442 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_folders_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_folders_handlers.py @@ -1,4 +1,3 @@ -import functools import logging from aiohttp import web @@ -8,33 +7,17 @@ from pydantic import BaseModel, ConfigDict, field_validator from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as -from servicelib.aiohttp.typing_extension import Handler from .._meta import api_version_prefix as VTAG from ..login.decorators import login_required from ..security.decorators import permission_required from . import _folders_api +from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import RequestContext -from .exceptions import ProjectGroupNotFoundError, ProjectNotFoundError _logger = logging.getLogger(__name__) -def _handle_projects_folders_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except ProjectGroupNotFoundError as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - - except ProjectNotFoundError as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - return wrapper - - routes = web.RouteTableDef() @@ -55,7 +38,7 @@ class _ProjectsFoldersPathParams(BaseModel): ) @login_required @permission_required("project.folders.*") -@_handle_projects_folders_exceptions +@handle_plugin_requests_exceptions async def replace_project_folder(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(_ProjectsFoldersPathParams, request) diff --git a/services/web/server/src/simcore_service_webserver/projects/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_groups_handlers.py index d507a2b1eff9..20128d6852f6 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_groups_handlers.py @@ -2,7 +2,6 @@ """ -import functools import logging from aiohttp import web @@ -14,35 +13,19 @@ parse_request_body_as, parse_request_path_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler from .._meta import api_version_prefix as VTAG from ..login.decorators import login_required from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _groups_api +from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ProjectPathParams, RequestContext from ._groups_api import ProjectGroupGet -from .exceptions import ProjectGroupNotFoundError, ProjectNotFoundError _logger = logging.getLogger(__name__) -def _handle_projects_groups_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except ProjectGroupNotFoundError as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - - except ProjectNotFoundError as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - return wrapper - - # # projects groups COLLECTION ------------------------- # @@ -68,7 +51,7 @@ class _ProjectsGroupsBodyParams(BaseModel): ) @login_required @permission_required("project.access_rights.update") -@_handle_projects_groups_exceptions +@handle_plugin_requests_exceptions async def create_project_group(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(_ProjectsGroupsPathParams, request) @@ -91,7 +74,7 @@ async def create_project_group(request: web.Request): @routes.get(f"/{VTAG}/projects/{{project_id}}/groups", name="list_project_groups") @login_required @permission_required("project.read") -@_handle_projects_groups_exceptions +@handle_plugin_requests_exceptions async def list_project_groups(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) @@ -114,7 +97,7 @@ async def list_project_groups(request: web.Request): ) @login_required @permission_required("project.access_rights.update") -@_handle_projects_groups_exceptions +@handle_plugin_requests_exceptions async def replace_project_group(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(_ProjectsGroupsPathParams, request) @@ -138,7 +121,7 @@ async def replace_project_group(request: web.Request): ) @login_required @permission_required("project.access_rights.update") -@_handle_projects_groups_exceptions +@handle_plugin_requests_exceptions async def delete_project_group(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(_ProjectsGroupsPathParams, request) diff --git a/services/web/server/src/simcore_service_webserver/projects/_metadata_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_metadata_handlers.py index df139c6fd30b..c6c1157e34ee 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_metadata_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_metadata_handlers.py @@ -10,7 +10,6 @@ - Get and Update methods only """ -import functools import logging from aiohttp import web @@ -22,7 +21,6 @@ parse_request_body_as, parse_request_path_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler from servicelib.logging_utils import log_catch from .._meta import api_version_prefix @@ -30,42 +28,14 @@ from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _metadata_api +from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ProjectPathParams, RequestContext -from .exceptions import ( - NodeNotFoundError, - ParentNodeNotFoundError, - ProjectInvalidRightsError, - ProjectInvalidUsageError, - ProjectNotFoundError, -) routes = web.RouteTableDef() _logger = logging.getLogger(__name__) -def _handle_project_exceptions(handler: Handler): - """Transforms project errors -> http errors""" - - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except ( - ProjectNotFoundError, - NodeNotFoundError, - ParentNodeNotFoundError, - ) as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - except ProjectInvalidRightsError as exc: - raise web.HTTPUnauthorized(reason=f"{exc}") from exc - except ProjectInvalidUsageError as exc: - raise web.HTTPUnprocessableEntity(reason=f"{exc}") from exc - - return wrapper - - # # projects/*/custom-metadata # @@ -77,7 +47,7 @@ async def wrapper(request: web.Request) -> web.StreamResponse: ) @login_required @permission_required("project.read") -@_handle_project_exceptions +@handle_plugin_requests_exceptions async def get_project_metadata(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) @@ -97,7 +67,7 @@ async def get_project_metadata(request: web.Request) -> web.Response: ) @login_required @permission_required("project.update") -@_handle_project_exceptions +@handle_plugin_requests_exceptions async def update_project_metadata(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py index 44ac05a12c72..28fcb974511b 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py @@ -3,7 +3,6 @@ """ import asyncio -import functools import logging from aiohttp import web @@ -42,17 +41,12 @@ parse_request_path_parameters_as, parse_request_query_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler from servicelib.common_headers import ( UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE, X_SIMCORE_USER_AGENT, ) from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from servicelib.rabbitmq import RPCServerError -from servicelib.rabbitmq.rpc_interfaces.catalog.errors import ( - CatalogForbiddenError, - CatalogItemNotFoundError, -) from servicelib.rabbitmq.rpc_interfaces.dynamic_scheduler.errors import ( ServiceWaitingForManualInterventionError, ServiceWasNotFoundError, @@ -67,70 +61,22 @@ from ..groups.exceptions import GroupNotFoundError from ..login.decorators import login_required from ..projects.api import has_user_project_access_rights -from ..resource_usage.errors import DefaultPricingPlanNotFoundError from ..security.decorators import permission_required from ..users.api import get_user_id_from_gid, get_user_role -from ..users.exceptions import UserDefaultWalletNotFoundError from ..utils_aiohttp import envelope_json_response -from ..wallets.errors import WalletAccessForbiddenError, WalletNotEnoughCreditsError from . import nodes_utils, projects_service +from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ProjectPathParams, RequestContext from ._nodes_api import NodeScreenshot, get_node_screenshots from .exceptions import ( - ClustersKeeperNotAvailableError, - DefaultPricingUnitNotFoundError, NodeNotFoundError, - ProjectInDebtCanNotChangeWalletError, - ProjectInvalidRightsError, - ProjectNodeRequiredInputsNotSetError, ProjectNodeResourcesInsufficientRightsError, ProjectNodeResourcesInvalidError, - ProjectNotFoundError, - ProjectStartsTooManyDynamicNodesError, ) _logger = logging.getLogger(__name__) -def _handle_project_nodes_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except ( - ProjectNotFoundError, - NodeNotFoundError, - UserDefaultWalletNotFoundError, - DefaultPricingPlanNotFoundError, - DefaultPricingUnitNotFoundError, - GroupNotFoundError, - CatalogItemNotFoundError, - ) as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - except ( - WalletNotEnoughCreditsError, - ProjectInDebtCanNotChangeWalletError, - ) as exc: - raise web.HTTPPaymentRequired(reason=f"{exc}") from exc - except ProjectInvalidRightsError as exc: - raise web.HTTPUnauthorized(reason=f"{exc}") from exc - except ProjectStartsTooManyDynamicNodesError as exc: - raise web.HTTPConflict(reason=f"{exc}") from exc - except ClustersKeeperNotAvailableError as exc: - raise web.HTTPServiceUnavailable(reason=f"{exc}") from exc - except ProjectNodeRequiredInputsNotSetError as exc: - raise web.HTTPConflict(reason=f"{exc}") from exc - except CatalogForbiddenError as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - except WalletAccessForbiddenError as exc: - raise web.HTTPForbidden( - reason=f"Payment required, but the user lacks access to the project's linked wallet.: {exc}" - ) from exc - - return wrapper - - # # projects/*/nodes COLLECTION ------------------------- # @@ -145,7 +91,7 @@ class NodePathParams(ProjectPathParams): @routes.post(f"/{VTAG}/projects/{{project_id}}/nodes", name="create_node") @login_required @permission_required("project.node.create") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def create_node(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) @@ -187,7 +133,7 @@ async def create_node(request: web.Request) -> web.Response: @routes.get(f"/{VTAG}/projects/{{project_id}}/nodes/{{node_id}}", name="get_node") @login_required @permission_required("project.node.read") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions # NOTE: Careful, this endpoint is actually "get_node_state," and it doesn't return a Node resource. async def get_node(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) @@ -226,7 +172,7 @@ async def get_node(request: web.Request) -> web.Response: ) @login_required @permission_required("project.node.update") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def patch_project_node(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(NodePathParams, request) @@ -247,7 +193,7 @@ async def patch_project_node(request: web.Request) -> web.Response: @routes.delete(f"/{VTAG}/projects/{{project_id}}/nodes/{{node_id}}", name="delete_node") @login_required @permission_required("project.node.delete") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def delete_node(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(NodePathParams, request) @@ -275,7 +221,7 @@ async def delete_node(request: web.Request) -> web.Response: ) @login_required @permission_required("project.node.read") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def retrieve_node(request: web.Request) -> web.Response: """Has only effect on nodes associated to dynamic services""" path_params = parse_request_path_parameters_as(NodePathParams, request) @@ -295,7 +241,7 @@ async def retrieve_node(request: web.Request) -> web.Response: ) @login_required @permission_required("project.node.update") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def update_node_outputs(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(NodePathParams, request) @@ -322,7 +268,7 @@ async def update_node_outputs(request: web.Request) -> web.Response: ) @login_required @permission_required("project.update") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def start_node(request: web.Request) -> web.Response: """Has only effect on nodes associated to dynamic services""" req_ctx = RequestContext.model_validate(request) @@ -366,7 +312,7 @@ async def _stop_dynamic_service_task( ) @login_required @permission_required("project.update") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def stop_node(request: web.Request) -> web.Response: """Has only effect on nodes associated to dynamic services""" req_ctx = RequestContext.model_validate(request) @@ -408,7 +354,7 @@ async def stop_node(request: web.Request) -> web.Response: ) @login_required @permission_required("project.node.read") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def restart_node(request: web.Request) -> web.Response: """Has only effect on nodes associated to dynamic services""" @@ -432,7 +378,7 @@ async def restart_node(request: web.Request) -> web.Response: ) @login_required @permission_required("project.node.read") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def get_node_resources(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(NodePathParams, request) @@ -465,7 +411,7 @@ async def get_node_resources(request: web.Request) -> web.Response: ) @login_required @permission_required("project.node.update") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def replace_node_resources(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(NodePathParams, request) @@ -524,7 +470,7 @@ class _ProjectGroupAccess(BaseModel): ) @login_required @permission_required("project.read") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def get_project_services_access_for_gid( request: web.Request, ) -> web.Response: @@ -644,7 +590,7 @@ class _ProjectNodePreview(BaseModel): ) @login_required @permission_required("project.read") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def list_project_nodes_previews(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) @@ -684,7 +630,7 @@ async def list_project_nodes_previews(request: web.Request) -> web.Response: ) @login_required @permission_required("project.read") -@_handle_project_nodes_exceptions +@handle_plugin_requests_exceptions async def get_project_node_preview(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(NodePathParams, request) diff --git a/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py index b134929a8afb..7fefd71ce0ca 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py @@ -3,13 +3,10 @@ - /projects/{*}/outputs """ -import functools import logging -from collections.abc import Awaitable, Callable from typing import Any, Literal from aiohttp import web -from common_library.json_serialization import json_dumps from models_library.api_schemas_webserver.projects_ports import ( ProjectInputGet, ProjectInputUpdate, @@ -27,57 +24,21 @@ parse_request_body_as, parse_request_path_parameters_as, ) +from simcore_service_webserver.utils_aiohttp import envelope_json_response from .._meta import API_VTAG as VTAG from ..login.decorators import login_required from ..projects._access_rights_api import check_user_project_permission from ..security.decorators import permission_required from . import _ports_api, projects_service +from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ProjectPathParams, RequestContext from .db import ProjectDBAPI -from .exceptions import ( - NodeNotFoundError, - ProjectInvalidRightsError, - ProjectNotFoundError, -) from .models import ProjectDict log = logging.getLogger(__name__) -def _web_json_response_enveloped(data: Any) -> web.Response: - return web.json_response( - { - "data": jsonable_encoder(data), - }, - dumps=json_dumps, - ) - - -def _handle_project_exceptions( - handler: Callable[[web.Request], Awaitable[web.Response]] -) -> Callable[[web.Request], Awaitable[web.Response]]: - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.Response: - try: - return await handler(request) - - except ProjectNotFoundError as exc: - raise web.HTTPNotFound( - reason=f"Project '{exc.project_uuid}' not found" - ) from exc - - except ProjectInvalidRightsError as exc: - raise web.HTTPUnauthorized from exc - - except NodeNotFoundError as exc: - raise web.HTTPNotFound( - reason=f"Port '{exc.node_uuid}' not found in node '{exc.project_uuid}'" - ) from exc - - return wrapper - - async def _get_validated_workbench_model( app: web.Application, project_id: ProjectID, user_id: UserID ) -> dict[NodeID, Node]: @@ -101,7 +62,7 @@ async def _get_validated_workbench_model( @routes.get(f"/{VTAG}/projects/{{project_id}}/inputs", name="get_project_inputs") @login_required @permission_required("project.read") -@_handle_project_exceptions +@handle_plugin_requests_exceptions async def get_project_inputs(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) @@ -113,8 +74,8 @@ async def get_project_inputs(request: web.Request) -> web.Response: ) inputs: dict[NodeID, Any] = _ports_api.get_project_inputs(workbench) - return _web_json_response_enveloped( - data={ + return envelope_json_response( + { node_id: ProjectInputGet( key=node_id, label=workbench[node_id].label, value=value ) @@ -126,7 +87,7 @@ async def get_project_inputs(request: web.Request) -> web.Response: @routes.patch(f"/{VTAG}/projects/{{project_id}}/inputs", name="update_project_inputs") @login_required @permission_required("project.update") -@_handle_project_exceptions +@handle_plugin_requests_exceptions async def update_project_inputs(request: web.Request) -> web.Response: db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(request.app) req_ctx = RequestContext.model_validate(request) @@ -174,8 +135,8 @@ async def update_project_inputs(request: web.Request) -> web.Response: ) inputs: dict[NodeID, Any] = _ports_api.get_project_inputs(workbench) - return _web_json_response_enveloped( - data={ + return envelope_json_response( + { node_id: ProjectInputGet( key=node_id, label=workbench[node_id].label, value=value ) @@ -192,7 +153,7 @@ async def update_project_inputs(request: web.Request) -> web.Response: @routes.get(f"/{VTAG}/projects/{{project_id}}/outputs", name="get_project_outputs") @login_required @permission_required("project.read") -@_handle_project_exceptions +@handle_plugin_requests_exceptions async def get_project_outputs(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) @@ -206,8 +167,8 @@ async def get_project_outputs(request: web.Request) -> web.Response: request.app, project_id=path_params.project_id, workbench=workbench ) - return _web_json_response_enveloped( - data={ + return envelope_json_response( + { node_id: ProjectOutputGet( key=node_id, label=workbench[node_id].label, value=value ) @@ -239,7 +200,7 @@ class ProjectMetadataPortGet(BaseModel): ) @login_required @permission_required("project.read") -@_handle_project_exceptions +@handle_plugin_requests_exceptions async def list_project_metadata_ports(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) @@ -250,8 +211,8 @@ async def list_project_metadata_ports(request: web.Request) -> web.Response: app=request.app, project_id=path_params.project_id, user_id=req_ctx.user_id ) - return _web_json_response_enveloped( - data=[ + return envelope_json_response( + [ ProjectMetadataPortGet( key=port.node_id, kind=port.kind, diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_nodes_pricing_unit_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_projects_nodes_pricing_unit_handlers.py index 66b17383bba7..fe39871840f7 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_nodes_pricing_unit_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_nodes_pricing_unit_handlers.py @@ -2,7 +2,6 @@ """ -import functools import logging from aiohttp import web @@ -13,7 +12,6 @@ from models_library.resource_tracker import PricingPlanId, PricingUnitId from pydantic import BaseModel, ConfigDict from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as -from servicelib.aiohttp.typing_extension import Handler from .._meta import API_VTAG from ..login.decorators import login_required @@ -21,10 +19,10 @@ from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import projects_service +from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import RequestContext from ._nodes_handlers import NodePathParams from .db import ProjectDBAPI -from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError _logger = logging.getLogger(__name__) @@ -37,21 +35,6 @@ class PricingUnitNotFoundError(PricingUnitError): msg_template = "Pricing unit not found" -def _handle_projects_nodes_pricing_unit_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except ProjectNotFoundError as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - - except (PricingUnitNotFoundError, ProjectInvalidRightsError) as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - return wrapper - - routes = web.RouteTableDef() @@ -61,7 +44,7 @@ async def wrapper(request: web.Request) -> web.StreamResponse: ) @login_required @permission_required("project.wallet.*") -@_handle_projects_nodes_pricing_unit_exceptions +@handle_plugin_requests_exceptions async def get_project_node_pricing_unit(request: web.Request): db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(request.app) req_ctx = RequestContext.model_validate(request) @@ -108,7 +91,7 @@ class _ProjectNodePricingUnitPathParams(BaseModel): ) @login_required @permission_required("project.wallet.*") -@_handle_projects_nodes_pricing_unit_exceptions +@handle_plugin_requests_exceptions async def connect_pricing_unit_to_project_node(request: web.Request): db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(request.app) req_ctx = RequestContext.model_validate(request) diff --git a/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py index 956226d7f32d..ecac998f854f 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py @@ -1,7 +1,6 @@ """handlers for project states""" import contextlib -import functools import json import logging @@ -14,7 +13,6 @@ parse_request_path_parameters_as, parse_request_query_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler from servicelib.aiohttp.web_exceptions_extension import HTTPLockedError from servicelib.common_headers import ( UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE, @@ -28,24 +26,14 @@ from ..login.decorators import login_required from ..notifications import project_logs from ..products.api import Product, get_current_product -from ..resource_usage.errors import DefaultPricingPlanNotFoundError from ..security.decorators import permission_required from ..users import api -from ..users.exceptions import UserDefaultWalletNotFoundError from ..utils_aiohttp import envelope_json_response -from ..wallets.errors import WalletNotEnoughCreditsError from . import api as projects_api from . import projects_service +from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ProjectPathParams, RequestContext -from .exceptions import ( - DefaultPricingUnitNotFoundError, - ProjectInDebtCanNotChangeWalletError, - ProjectInDebtCanNotOpenError, - ProjectInvalidRightsError, - ProjectNotFoundError, - ProjectStartsTooManyDynamicNodesError, - ProjectTooManyProjectOpenedError, -) +from .exceptions import ProjectStartsTooManyDynamicNodesError _logger = logging.getLogger(__name__) @@ -53,38 +41,6 @@ routes = web.RouteTableDef() -def _handle_project_exceptions(handler: Handler): - """Transforms common project errors -> http errors""" - - @functools.wraps(handler) - async def _wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except ( - ProjectNotFoundError, - UserDefaultWalletNotFoundError, - DefaultPricingPlanNotFoundError, - DefaultPricingUnitNotFoundError, - ) as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - - except ProjectInvalidRightsError as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - except ProjectTooManyProjectOpenedError as exc: - raise web.HTTPConflict(reason=f"{exc}") from exc - - except ( - WalletNotEnoughCreditsError, - ProjectInDebtCanNotChangeWalletError, - ProjectInDebtCanNotOpenError, - ) as exc: - raise web.HTTPPaymentRequired(reason=f"{exc}") from exc - - return _wrapper - - # # open project: custom methods https://google.aip.dev/136 # @@ -97,7 +53,7 @@ class _OpenProjectQuery(BaseModel): @routes.post(f"/{VTAG}/projects/{{project_id}}:open", name="open_project") @login_required @permission_required("project.open") -@_handle_project_exceptions +@handle_plugin_requests_exceptions async def open_project(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) @@ -208,7 +164,7 @@ async def open_project(request: web.Request) -> web.Response: @routes.post(f"/{VTAG}/projects/{{project_id}}:close", name="close_project") @login_required @permission_required("project.close") -@_handle_project_exceptions +@handle_plugin_requests_exceptions async def close_project(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) @@ -247,6 +203,7 @@ async def close_project(request: web.Request) -> web.Response: @routes.get(f"/{VTAG}/projects/{{project_id}}/state", name="get_project_state") @login_required @permission_required("project.read") +@handle_plugin_requests_exceptions async def get_project_state(request: web.Request) -> web.Response: req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) diff --git a/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py index 1eff696a1773..49ce6650ca0e 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py @@ -13,6 +13,7 @@ from ..login.decorators import login_required from ..security.decorators import permission_required from . import _tags_api as tags_api +from ._common.exceptions_handlers import handle_plugin_requests_exceptions _logger = logging.getLogger(__name__) @@ -25,6 +26,7 @@ ) @login_required @permission_required("project.tag.*") +@handle_plugin_requests_exceptions async def add_project_tag(request: web.Request): user_id: int = request[RQT_USERID_KEY] @@ -51,6 +53,7 @@ async def add_project_tag(request: web.Request): ) @login_required @permission_required("project.tag.*") +@handle_plugin_requests_exceptions async def remove_project_tag(request: web.Request): user_id: int = request[RQT_USERID_KEY] diff --git a/services/web/server/src/simcore_service_webserver/projects/_trash_rest.py b/services/web/server/src/simcore_service_webserver/projects/_trash_rest.py index 22368285efc6..daa6010d0f44 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_trash_rest.py +++ b/services/web/server/src/simcore_service_webserver/projects/_trash_rest.py @@ -18,17 +18,14 @@ from ..products.api import get_product_name from ..security.decorators import permission_required from . import _trash_service +from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ProjectPathParams, RemoveQueryParams from .exceptions import ProjectRunningConflictError, ProjectStoppingError _logger = logging.getLogger(__name__) -# -# EXCEPTIONS HANDLING -# - -_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { +_TRASH_ERRORS: ExceptionToHttpErrorMap = { ProjectRunningConflictError: HttpErrorInfo( status.HTTP_409_CONFLICT, "Current study is in use and cannot be trashed [project_id={project_uuid}]. Please stop all services first and try again", @@ -39,23 +36,19 @@ ), } - -_handle_exceptions = exception_handling_decorator( - to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +_handle_local_request_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TRASH_ERRORS) ) -# -# ROUTES -# - routes = web.RouteTableDef() @routes.post(f"/{VTAG}/projects/{{project_id}}:trash", name="trash_project") @login_required @permission_required("project.delete") -@_handle_exceptions +@handle_plugin_requests_exceptions +@_handle_local_request_exceptions async def trash_project(request: web.Request): user_id = get_user_id(request) product_name = get_product_name(request) @@ -79,7 +72,8 @@ async def trash_project(request: web.Request): @routes.post(f"/{VTAG}/projects/{{project_id}}:untrash", name="untrash_project") @login_required @permission_required("project.delete") -@_handle_exceptions +@handle_plugin_requests_exceptions +@_handle_local_request_exceptions async def untrash_project(request: web.Request): user_id = get_user_id(request) product_name = get_product_name(request) diff --git a/services/web/server/src/simcore_service_webserver/projects/_trash_service.py b/services/web/server/src/simcore_service_webserver/projects/_trash_service.py index 77358dbd7764..7f3bf1f9adaa 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_trash_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_trash_service.py @@ -192,11 +192,13 @@ async def list_trashed_projects( # This filtering couldn't be handled at the database level when `projects_repo` # was refactored, as defining a custom trash_filter was needed to allow more # flexibility in filtering options. - trashed_projects = [ - project["uuid"] - for project in projects - if _can_delete(project, user_id, until_equal_datetime) - ] + trashed_projects.extend( + [ + project["uuid"] + for project in projects + if _can_delete(project, user_id, until_equal_datetime) + ] + ) return trashed_projects diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py index 346958c34879..79a0e6d0cf37 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py @@ -2,7 +2,6 @@ """ -import functools import logging from decimal import Decimal from typing import Annotated @@ -17,58 +16,26 @@ parse_request_body_as, parse_request_path_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler from simcore_service_webserver.utils_aiohttp import envelope_json_response from .._meta import API_VTAG from ..login.decorators import login_required from ..security.decorators import permission_required -from ..wallets.errors import WalletAccessForbiddenError, WalletNotFoundError from . import _wallets_api as wallets_api from . import projects_service +from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ProjectPathParams, RequestContext -from .exceptions import ( - ProjectInDebtCanNotChangeWalletError, - ProjectInvalidRightsError, - ProjectNotFoundError, - ProjectWalletPendingTransactionError, -) _logger = logging.getLogger(__name__) -def _handle_project_wallet_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except ProjectNotFoundError as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - - except WalletNotFoundError as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - - except ProjectInDebtCanNotChangeWalletError as exc: - raise web.HTTPPaymentRequired(reason=f"{exc}") from exc - - except ( - WalletAccessForbiddenError, - ProjectInvalidRightsError, - ProjectWalletPendingTransactionError, - ) as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - return wrapper - - routes = web.RouteTableDef() @routes.get(f"/{API_VTAG}/projects/{{project_id}}/wallet", name="get_project_wallet") @login_required @permission_required("project.wallet.*") -@_handle_project_wallet_exceptions +@handle_plugin_requests_exceptions async def get_project_wallet(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) @@ -99,7 +66,7 @@ class _ProjectWalletPathParams(BaseModel): ) @login_required @permission_required("project.wallet.*") -@_handle_project_wallet_exceptions +@handle_plugin_requests_exceptions async def connect_wallet_to_project(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(_ProjectWalletPathParams, request) @@ -134,7 +101,7 @@ class _PayProjectDebtBody(BaseModel): ) @login_required @permission_required("project.wallet.*") -@_handle_project_wallet_exceptions +@handle_plugin_requests_exceptions async def pay_project_debt(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(_ProjectWalletPathParams, request) diff --git a/services/web/server/src/simcore_service_webserver/projects/_workspaces_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_workspaces_handlers.py index b5a6082cb506..2421670eb6e1 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_workspaces_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_workspaces_handlers.py @@ -1,4 +1,3 @@ -import functools import logging from typing import Annotated @@ -9,43 +8,17 @@ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as -from servicelib.aiohttp.typing_extension import Handler from .._meta import api_version_prefix as VTAG -from ..folders.errors import FolderAccessForbiddenError, FolderNotFoundError from ..login.decorators import login_required from ..security.decorators import permission_required -from ..workspaces.errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError from . import _workspaces_api +from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import RequestContext -from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError _logger = logging.getLogger(__name__) -def _handle_projects_workspaces_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except ( - ProjectNotFoundError, - FolderNotFoundError, - WorkspaceNotFoundError, - ) as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - - except ( - ProjectInvalidRightsError, - FolderAccessForbiddenError, - WorkspaceAccessForbiddenError, - ) as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - return wrapper - - routes = web.RouteTableDef() @@ -64,7 +37,7 @@ class _ProjectWorkspacesPathParams(BaseModel): ) @login_required @permission_required("project.workspaces.*") -@_handle_projects_workspaces_exceptions +@handle_plugin_requests_exceptions async def move_project_to_workspace(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as( diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_metadata_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_metadata_handlers.py index ce87970f75c6..dae450a88fe7 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_metadata_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_metadata_handlers.py @@ -63,7 +63,7 @@ async def test_custom_metadata_handlers( response = await client.get(f"{url}") _, error = await assert_status(response, expected_status_code=expected.not_found) - error_message = error["errors"][0]["message"] + error_message = error["message"] assert invalid_project_id in error_message assert "project" in error_message.lower() diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__patch.py b/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__patch.py index 1f6f18cc00a3..77890e530c8d 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__patch.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__patch.py @@ -356,7 +356,7 @@ async def test_patch_project_node_service_key_with_error( ): node_id = next(iter(user_project["workbench"])) assert client.app - base_url = client.app.router["patch_project_node"].url_for( + url = client.app.router["patch_project_node"].url_for( project_id=user_project["uuid"], node_id=node_id ) _patch_version = {"version": "2.0.9"} @@ -365,12 +365,12 @@ async def test_patch_project_node_service_key_with_error( "simcore_service_webserver.projects.projects_service.catalog_rpc.check_for_service", side_effect=CatalogForbiddenError(name="test"), ): - resp = await client.patch(f"{base_url}", json=_patch_version) + resp = await client.patch(f"{url}", json=_patch_version) assert resp.status == status.HTTP_403_FORBIDDEN with mocker.patch( "simcore_service_webserver.projects.projects_service.catalog_rpc.check_for_service", side_effect=CatalogItemNotFoundError(name="test"), ): - resp = await client.patch(f"{base_url}", json=_patch_version) + resp = await client.patch(f"{url}", json=_patch_version) assert resp.status == status.HTTP_404_NOT_FOUND