From dd5924747d4c00a59804dac91424b0f0c4b9bd5a Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Wed, 8 Nov 2023 08:22:13 -0800 Subject: [PATCH 1/2] Add ConnectionProblemReport handler Signed-off-by: Jason Sherman --- .../v1_0/handlers/problem_report_handler.py | 35 +++++++++++++++++++ .../protocols/connections/v1_0/manager.py | 23 +++++++++++- .../v1_0/messages/problem_report.py | 3 +- 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 aries_cloudagent/protocols/connections/v1_0/handlers/problem_report_handler.py diff --git a/aries_cloudagent/protocols/connections/v1_0/handlers/problem_report_handler.py b/aries_cloudagent/protocols/connections/v1_0/handlers/problem_report_handler.py new file mode 100644 index 0000000000..70ea0e17f6 --- /dev/null +++ b/aries_cloudagent/protocols/connections/v1_0/handlers/problem_report_handler.py @@ -0,0 +1,35 @@ +"""Problem report handler for Connection Protocol.""" + +from .....messaging.base_handler import ( + BaseHandler, + BaseResponder, + HandlerException, + RequestContext, +) +from ..manager import ConnectionManager, ConnectionManagerError +from ..messages.problem_report import ConnectionProblemReport + + +class ConnectionProblemReportHandler(BaseHandler): + """Handler class for Connection problem report messages.""" + + async def handle(self, context: RequestContext, responder: BaseResponder): + """Handle problem report message.""" + self._logger.debug( + f"ConnectionProblemReportHandler called with context {context}" + ) + assert isinstance(context.message, ConnectionProblemReport) + + self._logger.info(f"Received problem report: {context.message.problem_code}") + profile = context.profile + mgr = ConnectionManager(profile) + try: + if context.connection_record: + await mgr.receive_problem_report( + context.connection_record, context.message + ) + else: + raise HandlerException("No connection established for problem report") + except ConnectionManagerError: + # Unrecognized problem report code + self._logger.exception("Error receiving Connection problem report") diff --git a/aries_cloudagent/protocols/connections/v1_0/manager.py b/aries_cloudagent/protocols/connections/v1_0/manager.py index 7a77cd6739..af272e8314 100644 --- a/aries_cloudagent/protocols/connections/v1_0/manager.py +++ b/aries_cloudagent/protocols/connections/v1_0/manager.py @@ -21,7 +21,7 @@ from .messages.connection_invitation import ConnectionInvitation from .messages.connection_request import ConnectionRequest from .messages.connection_response import ConnectionResponse -from .messages.problem_report import ProblemReportReason +from .messages.problem_report import ConnectionProblemReport, ProblemReportReason from .models.connection_detail import ConnectionDetail @@ -757,3 +757,24 @@ async def accept_response( await responder.send(request, connection_id=connection.connection_id) return connection + + async def receive_problem_report( + self, + conn_rec: ConnRecord, + report: ConnectionProblemReport, + ): + """Receive problem report.""" + if not report.problem_code: + raise ConnectionManagerError("Missing problem_code in problem report") + + if report.problem_code in {reason.value for reason in ProblemReportReason}: + self._logger.info("Problem report indicates connection is abandoned") + async with self.profile.session() as session: + await conn_rec.abandon( + session, + reason=report.problem_code, + ) + else: + raise ConnectionManagerError( + f"Received unrecognized problem report: {report.problem_code}" + ) diff --git a/aries_cloudagent/protocols/connections/v1_0/messages/problem_report.py b/aries_cloudagent/protocols/connections/v1_0/messages/problem_report.py index 7f118d19cd..38435300b4 100644 --- a/aries_cloudagent/protocols/connections/v1_0/messages/problem_report.py +++ b/aries_cloudagent/protocols/connections/v1_0/messages/problem_report.py @@ -8,7 +8,8 @@ from ..message_types import PROBLEM_REPORT HANDLER_CLASS = ( - "aries_cloudagent.protocols.problem_report.v1_0.handler.ProblemReportHandler" + "aries_cloudagent.protocols.connections.v1_0.handlers." + "problem_report_handler.ConnectionProblemReportHandler" ) From f9853729b9972f267693449c74764e80d8a0a373 Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Fri, 1 Dec 2023 15:22:28 -0800 Subject: [PATCH 2/2] update connectionmanagererror and connectionproblemreport to be consistent with didexchange Signed-off-by: Jason Sherman --- .../connections/models/conn_record.py | 2 +- .../handlers/connection_invitation_handler.py | 9 ++-- .../handlers/connection_request_handler.py | 4 +- .../handlers/connection_response_handler.py | 5 +- .../handlers/tests/test_invitation_handler.py | 7 ++- .../handlers/tests/test_request_handler.py | 29 +++++++---- .../handlers/tests/test_response_handler.py | 28 +++++++---- .../protocols/connections/v1_0/manager.py | 24 +++++----- .../v1_0/messages/problem_report.py | 48 ++++++++++--------- 9 files changed, 96 insertions(+), 60 deletions(-) diff --git a/aries_cloudagent/connections/models/conn_record.py b/aries_cloudagent/connections/models/conn_record.py index 24fc2fbf34..7418fca11b 100644 --- a/aries_cloudagent/connections/models/conn_record.py +++ b/aries_cloudagent/connections/models/conn_record.py @@ -542,7 +542,7 @@ async def delete_record(self, session: ProfileSession): async def abandon(self, session: ProfileSession, *, reason: Optional[str] = None): """Set state to abandoned.""" - reason = reason or "Connectin abandoned" + reason = reason or "Connection abandoned" self.state = ConnRecord.State.ABANDONED.rfc160 self.error_msg = reason await self.save(session, reason=reason) diff --git a/aries_cloudagent/protocols/connections/v1_0/handlers/connection_invitation_handler.py b/aries_cloudagent/protocols/connections/v1_0/handlers/connection_invitation_handler.py index 07da95b91e..babfedb5eb 100644 --- a/aries_cloudagent/protocols/connections/v1_0/handlers/connection_invitation_handler.py +++ b/aries_cloudagent/protocols/connections/v1_0/handlers/connection_invitation_handler.py @@ -5,7 +5,6 @@ BaseResponder, RequestContext, ) - from ..messages.connection_invitation import ConnectionInvitation from ..messages.problem_report import ConnectionProblemReport, ProblemReportReason @@ -25,8 +24,12 @@ async def handle(self, context: RequestContext, responder: BaseResponder): assert isinstance(context.message, ConnectionInvitation) report = ConnectionProblemReport( - problem_code=ProblemReportReason.INVITATION_NOT_ACCEPTED, - explain="Connection invitations cannot be submitted via agent messaging", + description={ + "code": ProblemReportReason.INVITATION_NOT_ACCEPTED.value, + "en": ( + "Connection invitations cannot be submitted via agent messaging" + ), + } ) # client likely needs to be using direct responses to receive the problem report await responder.send_reply(report) diff --git a/aries_cloudagent/protocols/connections/v1_0/handlers/connection_request_handler.py b/aries_cloudagent/protocols/connections/v1_0/handlers/connection_request_handler.py index 73efd9ecf8..2f55b4a551 100644 --- a/aries_cloudagent/protocols/connections/v1_0/handlers/connection_request_handler.py +++ b/aries_cloudagent/protocols/connections/v1_0/handlers/connection_request_handler.py @@ -63,6 +63,8 @@ async def handle(self, context: RequestContext, responder: BaseResponder): "Error parsing DIDDoc for problem report" ) await responder.send_reply( - ConnectionProblemReport(problem_code=e.error_code, explain=str(e)), + ConnectionProblemReport( + description={"en": e.message, "code": e.error_code} + ), target_list=targets, ) diff --git a/aries_cloudagent/protocols/connections/v1_0/handlers/connection_response_handler.py b/aries_cloudagent/protocols/connections/v1_0/handlers/connection_response_handler.py index 5bb79269db..93d7c4163d 100644 --- a/aries_cloudagent/protocols/connections/v1_0/handlers/connection_response_handler.py +++ b/aries_cloudagent/protocols/connections/v1_0/handlers/connection_response_handler.py @@ -6,7 +6,6 @@ RequestContext, ) from .....protocols.trustping.v1_0.messages.ping import Ping - from ..manager import ConnectionManager, ConnectionManagerError from ..messages.connection_response import ConnectionResponse from ..messages.problem_report import ConnectionProblemReport @@ -46,7 +45,9 @@ async def handle(self, context: RequestContext, responder: BaseResponder): "Error parsing DIDDoc for problem report" ) await responder.send_reply( - ConnectionProblemReport(problem_code=e.error_code, explain=str(e)), + ConnectionProblemReport( + description={"en": e.message, "code": e.error_code} + ), target_list=targets, ) return diff --git a/aries_cloudagent/protocols/connections/v1_0/handlers/tests/test_invitation_handler.py b/aries_cloudagent/protocols/connections/v1_0/handlers/tests/test_invitation_handler.py index 3e524d9a19..d35c7c3be1 100644 --- a/aries_cloudagent/protocols/connections/v1_0/handlers/tests/test_invitation_handler.py +++ b/aries_cloudagent/protocols/connections/v1_0/handlers/tests/test_invitation_handler.py @@ -3,7 +3,6 @@ from ......messaging.request_context import RequestContext from ......messaging.responder import MockResponder from ......transport.inbound.receipt import MessageReceipt - from ...handlers.connection_invitation_handler import ConnectionInvitationHandler from ...messages.connection_invitation import ConnectionInvitation from ...messages.problem_report import ConnectionProblemReport, ProblemReportReason @@ -28,6 +27,10 @@ async def test_problem_report(self, request_context): result, target = messages[0] assert ( isinstance(result, ConnectionProblemReport) - and result.problem_code == ProblemReportReason.INVITATION_NOT_ACCEPTED + and result.description + and ( + result.description["code"] + == ProblemReportReason.INVITATION_NOT_ACCEPTED.value + ) ) assert not target diff --git a/aries_cloudagent/protocols/connections/v1_0/handlers/tests/test_request_handler.py b/aries_cloudagent/protocols/connections/v1_0/handlers/tests/test_request_handler.py index 1937deb549..7b68ac2b32 100644 --- a/aries_cloudagent/protocols/connections/v1_0/handlers/tests/test_request_handler.py +++ b/aries_cloudagent/protocols/connections/v1_0/handlers/tests/test_request_handler.py @@ -1,15 +1,16 @@ import pytest + from aries_cloudagent.tests import mock -from ......core.profile import ProfileSession from ......connections.models import connection_target from ......connections.models.conn_record import ConnRecord from ......connections.models.diddoc import DIDDoc, PublicKey, PublicKeyType, Service +from ......core.profile import ProfileSession from ......messaging.request_context import RequestContext from ......messaging.responder import MockResponder -from ......transport.inbound.receipt import MessageReceipt from ......storage.base import BaseStorage from ......storage.error import StorageNotFoundError +from ......transport.inbound.receipt import MessageReceipt from ...handlers import connection_request_handler as handler from ...manager import ConnectionManagerError from ...messages.connection_request import ConnectionRequest @@ -161,7 +162,7 @@ async def test_connection_record_without_mediation_metadata( async def test_problem_report(self, mock_conn_mgr, request_context): mock_conn_mgr.return_value.receive_request = mock.CoroutineMock() mock_conn_mgr.return_value.receive_request.side_effect = ConnectionManagerError( - error_code=ProblemReportReason.REQUEST_NOT_ACCEPTED + error_code=ProblemReportReason.REQUEST_NOT_ACCEPTED.value ) request_context.message = ConnectionRequest() handler_inst = handler.ConnectionRequestHandler() @@ -172,7 +173,11 @@ async def test_problem_report(self, mock_conn_mgr, request_context): result, target = messages[0] assert ( isinstance(result, ConnectionProblemReport) - and result.problem_code == ProblemReportReason.REQUEST_NOT_ACCEPTED + and result.description + and ( + result.description["code"] + == ProblemReportReason.REQUEST_NOT_ACCEPTED.value + ) ) assert target == {"target_list": None} @@ -184,7 +189,7 @@ async def test_problem_report_did_doc( ): mock_conn_mgr.return_value.receive_request = mock.CoroutineMock() mock_conn_mgr.return_value.receive_request.side_effect = ConnectionManagerError( - error_code=ProblemReportReason.REQUEST_NOT_ACCEPTED + error_code=ProblemReportReason.REQUEST_NOT_ACCEPTED.value ) mock_conn_mgr.return_value.diddoc_connection_targets = mock.MagicMock( return_value=[mock_conn_target] @@ -202,7 +207,11 @@ async def test_problem_report_did_doc( result, target = messages[0] assert ( isinstance(result, ConnectionProblemReport) - and result.problem_code == ProblemReportReason.REQUEST_NOT_ACCEPTED + and result.description + and ( + result.description["code"] + == ProblemReportReason.REQUEST_NOT_ACCEPTED.value + ) ) assert target == {"target_list": [mock_conn_target]} @@ -214,7 +223,7 @@ async def test_problem_report_did_doc_no_conn_target( ): mock_conn_mgr.return_value.receive_request = mock.CoroutineMock() mock_conn_mgr.return_value.receive_request.side_effect = ConnectionManagerError( - error_code=ProblemReportReason.REQUEST_NOT_ACCEPTED + error_code=ProblemReportReason.REQUEST_NOT_ACCEPTED.value ) mock_conn_mgr.return_value.diddoc_connection_targets = mock.MagicMock( side_effect=ConnectionManagerError("no targets") @@ -232,6 +241,10 @@ async def test_problem_report_did_doc_no_conn_target( result, target = messages[0] assert ( isinstance(result, ConnectionProblemReport) - and result.problem_code == ProblemReportReason.REQUEST_NOT_ACCEPTED + and result.description + and ( + result.description["code"] + == ProblemReportReason.REQUEST_NOT_ACCEPTED.value + ) ) assert target == {"target_list": None} diff --git a/aries_cloudagent/protocols/connections/v1_0/handlers/tests/test_response_handler.py b/aries_cloudagent/protocols/connections/v1_0/handlers/tests/test_response_handler.py index f5a08522ae..7f66336c77 100644 --- a/aries_cloudagent/protocols/connections/v1_0/handlers/tests/test_response_handler.py +++ b/aries_cloudagent/protocols/connections/v1_0/handlers/tests/test_response_handler.py @@ -1,4 +1,5 @@ import pytest + from aries_cloudagent.tests import mock from ......connections.models import connection_target @@ -10,11 +11,8 @@ ) from ......messaging.request_context import RequestContext from ......messaging.responder import MockResponder - from ......protocols.trustping.v1_0.messages.ping import Ping - from ......transport.inbound.receipt import MessageReceipt - from ...handlers import connection_response_handler as handler from ...manager import ConnectionManagerError from ...messages.connection_response import ConnectionResponse @@ -101,7 +99,7 @@ async def test_called_auto_ping(self, mock_conn_mgr, request_context): async def test_problem_report(self, mock_conn_mgr, request_context): mock_conn_mgr.return_value.accept_response = mock.CoroutineMock() mock_conn_mgr.return_value.accept_response.side_effect = ConnectionManagerError( - error_code=ProblemReportReason.RESPONSE_NOT_ACCEPTED + error_code=ProblemReportReason.RESPONSE_NOT_ACCEPTED.value, ) request_context.message = ConnectionResponse() handler_inst = handler.ConnectionResponseHandler() @@ -112,7 +110,11 @@ async def test_problem_report(self, mock_conn_mgr, request_context): result, target = messages[0] assert ( isinstance(result, ConnectionProblemReport) - and result.problem_code == ProblemReportReason.RESPONSE_NOT_ACCEPTED + and result.description + and ( + result.description["code"] + == ProblemReportReason.RESPONSE_NOT_ACCEPTED.value + ) ) assert target == {"target_list": None} @@ -124,7 +126,7 @@ async def test_problem_report_did_doc( ): mock_conn_mgr.return_value.accept_response = mock.CoroutineMock() mock_conn_mgr.return_value.accept_response.side_effect = ConnectionManagerError( - error_code=ProblemReportReason.REQUEST_NOT_ACCEPTED + error_code=ProblemReportReason.RESPONSE_NOT_ACCEPTED.value, ) mock_conn_mgr.return_value.diddoc_connection_targets = mock.MagicMock( return_value=[mock_conn_target] @@ -140,7 +142,11 @@ async def test_problem_report_did_doc( result, target = messages[0] assert ( isinstance(result, ConnectionProblemReport) - and result.problem_code == ProblemReportReason.REQUEST_NOT_ACCEPTED + and result.description + and ( + result.description["code"] + == ProblemReportReason.RESPONSE_NOT_ACCEPTED.value + ) ) assert target == {"target_list": [mock_conn_target]} @@ -152,7 +158,7 @@ async def test_problem_report_did_doc_no_conn_target( ): mock_conn_mgr.return_value.accept_response = mock.CoroutineMock() mock_conn_mgr.return_value.accept_response.side_effect = ConnectionManagerError( - error_code=ProblemReportReason.REQUEST_NOT_ACCEPTED + error_code=ProblemReportReason.RESPONSE_NOT_ACCEPTED.value, ) mock_conn_mgr.return_value.diddoc_connection_targets = mock.MagicMock( side_effect=ConnectionManagerError("no target") @@ -168,6 +174,10 @@ async def test_problem_report_did_doc_no_conn_target( result, target = messages[0] assert ( isinstance(result, ConnectionProblemReport) - and result.problem_code == ProblemReportReason.REQUEST_NOT_ACCEPTED + and result.description + and ( + result.description["code"] + == ProblemReportReason.RESPONSE_NOT_ACCEPTED.value + ) ) assert target == {"target_list": None} diff --git a/aries_cloudagent/protocols/connections/v1_0/manager.py b/aries_cloudagent/protocols/connections/v1_0/manager.py index af272e8314..b7211ffcef 100644 --- a/aries_cloudagent/protocols/connections/v1_0/manager.py +++ b/aries_cloudagent/protocols/connections/v1_0/manager.py @@ -3,11 +3,10 @@ import logging from typing import Optional, Sequence, Tuple, cast - -from ....core.oob_processor import OobMessageProcessor from ....connections.base_manager import BaseConnectionManager from ....connections.models.conn_record import ConnRecord from ....core.error import BaseError +from ....core.oob_processor import OobMessageProcessor from ....core.profile import Profile from ....messaging.responder import BaseResponder from ....messaging.valid import IndyDID @@ -261,12 +260,12 @@ async def receive_invitation( if not invitation.recipient_keys: raise ConnectionManagerError( "Invitation must contain recipient key(s)", - error_code="missing-recipient-keys", + error_code=ProblemReportReason.MISSING_RECIPIENT_KEYS.value, ) if not invitation.endpoint: raise ConnectionManagerError( "Invitation must contain an endpoint", - error_code="missing-endpoint", + error_code=ProblemReportReason.MISSING_ENDPOINT.value, ) accept = ( ConnRecord.ACCEPT_AUTO @@ -440,7 +439,8 @@ async def receive_request( raise ConnectionManagerError( "No invitation found for pairwise connection " f"in state {ConnRecord.State.INVITATION.rfc160}: " - "a prior connection request may have updated the connection state" + "a prior connection request may have updated the connection state", + error_code=ProblemReportReason.REQUEST_NOT_ACCEPTED.value, ) invitation = None @@ -489,7 +489,7 @@ async def receive_request( conn_did_doc = request.connection.did_doc if not conn_did_doc: raise ConnectionManagerError( - "No DIDDoc provided; cannot connect to public DID" + "No DIDDoc provided; cannot connect to public DID", ) if request.connection.did != conn_did_doc.did: raise ConnectionManagerError( @@ -764,17 +764,19 @@ async def receive_problem_report( report: ConnectionProblemReport, ): """Receive problem report.""" - if not report.problem_code: - raise ConnectionManagerError("Missing problem_code in problem report") + if not report.description: + raise ConnectionManagerError("Missing description in problem report") - if report.problem_code in {reason.value for reason in ProblemReportReason}: + if report.description.get("code") in { + reason.value for reason in ProblemReportReason + }: self._logger.info("Problem report indicates connection is abandoned") async with self.profile.session() as session: await conn_rec.abandon( session, - reason=report.problem_code, + reason=report.description.get("en"), ) else: raise ConnectionManagerError( - f"Received unrecognized problem report: {report.problem_code}" + f"Received unrecognized problem report: {report.description}" ) diff --git a/aries_cloudagent/protocols/connections/v1_0/messages/problem_report.py b/aries_cloudagent/protocols/connections/v1_0/messages/problem_report.py index 38435300b4..2823f8539c 100644 --- a/aries_cloudagent/protocols/connections/v1_0/messages/problem_report.py +++ b/aries_cloudagent/protocols/connections/v1_0/messages/problem_report.py @@ -1,10 +1,11 @@ """Represents a connection problem report message.""" +import logging from enum import Enum -from marshmallow import EXCLUDE, fields, validate +from marshmallow import EXCLUDE, ValidationError, validates_schema -from .....messaging.agent_message import AgentMessage, AgentMessageSchema +from ....problem_report.v1_0.message import ProblemReport, ProblemReportSchema from ..message_types import PROBLEM_REPORT HANDLER_CLASS = ( @@ -12,6 +13,8 @@ "problem_report_handler.ConnectionProblemReportHandler" ) +LOGGER = logging.getLogger(__name__) + class ProblemReportReason(Enum): """Supported reason codes.""" @@ -21,9 +24,11 @@ class ProblemReportReason(Enum): REQUEST_PROCESSING_ERROR = "request_processing_error" RESPONSE_NOT_ACCEPTED = "response_not_accepted" RESPONSE_PROCESSING_ERROR = "response_processing_error" + MISSING_RECIPIENT_KEYS = "invitation_missing_recipient_keys" + MISSING_ENDPOINT = "invitation_missing_endpoint" -class ConnectionProblemReport(AgentMessage): +class ConnectionProblemReport(ProblemReport): """Base class representing a connection problem report message.""" class Meta: @@ -45,7 +50,7 @@ def __init__(self, *, problem_code: str = None, explain: str = None, **kwargs): self.problem_code = problem_code -class ConnectionProblemReportSchema(AgentMessageSchema): +class ConnectionProblemReportSchema(ProblemReportSchema): """Schema for ConnectionProblemReport base class.""" class Meta: @@ -54,22 +59,19 @@ class Meta: model_class = ConnectionProblemReport unknown = EXCLUDE - explain = fields.Str( - required=False, - metadata={ - "description": "Localized error explanation", - "example": "Invitation not accepted", - }, - ) - problem_code = fields.Str( - data_key="problem-code", - required=False, - validate=validate.OneOf( - choices=[prr.value for prr in ProblemReportReason], - error="Value {input} must be one of {choices}.", - ), - metadata={ - "description": "Standard error identifier", - "example": ProblemReportReason.INVITATION_NOT_ACCEPTED.value, - }, - ) + @validates_schema + def validate_fields(self, data, **kwargs): + """Validate schema fields.""" + + if not data.get("description", {}).get("code", ""): + raise ValidationError("Value for description.code must be present") + elif data.get("description", {}).get("code", "") not in [ + prr.value for prr in ProblemReportReason + ]: + locales = list(data.get("description").keys()) + locales.remove("code") + LOGGER.warning( + "Unexpected error code received.\n" + f"Code: {data.get('description').get('code')}, " + f"Description: {data.get('description').get(locales[0])}" + )