diff --git a/aries_cloudagent/config/default_context.py b/aries_cloudagent/config/default_context.py index b55d3b2609..7c213f5f3b 100644 --- a/aries_cloudagent/config/default_context.py +++ b/aries_cloudagent/config/default_context.py @@ -111,7 +111,9 @@ async def load_plugins(self, context: InjectionContext): "aries_cloudagent.messaging.credential_definitions" ) plugin_registry.register_plugin("aries_cloudagent.messaging.schemas") + # plugin_registry.register_plugin("aries_cloudagent.messaging.jsonld") plugin_registry.register_plugin("aries_cloudagent.revocation") + plugin_registry.register_plugin("aries_cloudagent.resolver") plugin_registry.register_plugin("aries_cloudagent.wallet") if context.settings.get("multitenant.admin_enabled"): diff --git a/aries_cloudagent/resolver/__init__.py b/aries_cloudagent/resolver/__init__.py index e69de29bb2..e9b68d8c85 100644 --- a/aries_cloudagent/resolver/__init__.py +++ b/aries_cloudagent/resolver/__init__.py @@ -0,0 +1 @@ +"""Interfaces and base classes for DID Resolution.""" diff --git a/aries_cloudagent/resolver/did_resolver.py b/aries_cloudagent/resolver/did_resolver.py index 7fcc4dec82..1ef0a67498 100644 --- a/aries_cloudagent/resolver/did_resolver.py +++ b/aries_cloudagent/resolver/did_resolver.py @@ -25,9 +25,7 @@ def __init__(self, registry: DIDResolverRegistry): """Initialize a `didresolver` instance.""" self.did_resolver_registry = registry - async def resolve( - self, profile: Profile, did: Union[str, DID] - ) -> ResolvedDIDDoc: + async def resolve(self, profile: Profile, did: Union[str, DID]) -> ResolvedDIDDoc: """Retrieve did doc from public registry.""" # TODO Cache results if isinstance(did, str): @@ -62,9 +60,7 @@ def _match_did_to_resolver(self, did: DID) -> BaseDIDResolver: raise DIDMethodNotSupported(f"{did.method} not supported") return resolvers - async def dereference( - self, profile: Profile, did_url: str - ) -> ResolvedDIDDoc: + async def dereference(self, profile: Profile, did_url: str) -> ResolvedDIDDoc: """Dereference a DID URL to its corresponding DID Doc object.""" # TODO Use cached DID Docs when possible did_url = DIDUrl.parse(did_url) diff --git a/aries_cloudagent/resolver/diddoc.py b/aries_cloudagent/resolver/diddoc.py index 3dd3486db6..52527bcc7f 100644 --- a/aries_cloudagent/resolver/diddoc.py +++ b/aries_cloudagent/resolver/diddoc.py @@ -61,6 +61,10 @@ def did(self): """Return the DID subject of this Document.""" return self._did + def serialize(self): + """Return the did document.""" + return self._doc + def didcomm_services(self) -> Sequence[dict]: """Return agent services in priority order.""" diff --git a/aries_cloudagent/resolver/routes.py b/aries_cloudagent/resolver/routes.py new file mode 100644 index 0000000000..2c83d9a643 --- /dev/null +++ b/aries_cloudagent/resolver/routes.py @@ -0,0 +1,146 @@ +""" +Resolve did document admin routes. + +"/resolver/resolve/{did}": { + "get": { + "responses": { + "200": { + "schema": { + "$ref": "#/definitions/DIDDoc" + }, + "description": null + } + }, + "parameters": [ + { + "in": "path", + "name": "did", + "required": true, + "type": "string", + "pattern": "did:([a-z]+):((?:[a-zA-Z0-9._-]*:)*[a-zA-Z0-9._-]+)", + "description": "decentralize identifier(DID)", + "example": "did:ted:WgWxqztrNooG92RXvxSTWv" + } + ], + "tags": [ "resolver" ], + "summary": "Retrieve doc for requested did", + "produces": [ "application/json" ] + } +} +""" + +import re +from aiohttp import web +from aiohttp_apispec import ( + docs, + match_info_schema, + response_schema, +) +from marshmallow import fields, validate + +from ..admin.request_context import AdminRequestContext +from ..messaging.models.base import BaseModelError +from ..messaging.models.openapi import OpenAPISchema +from ..storage.error import StorageError, StorageNotFoundError +from .did_resolver import DIDResolver +from .did import DID_PATTERN + +class W3cDIDDoc(validate.Regexp): + """Validate value against w3c DID document.""" + + EXAMPLE = "*" + PATTERN = DID_PATTERN + + def __init__(self): + """Initializer.""" + + super().__init__( + W3cDIDDoc.PATTERN, + error="Value {input} is not a w3c decentralized identifier (DID) doc.", + ) + + +_W3cDIDDoc = {"validate": W3cDIDDoc(), "example": W3cDIDDoc.EXAMPLE} + + +class DIDDocSchema(OpenAPISchema): + """Result schema for did document query.""" + + did_doc = fields.Str( + description="decentralize identifier(DID) document", required=True, **_W3cDIDDoc + ) + + +class W3cDID(validate.Regexp): + """Validate value against w3c DID.""" + + EXAMPLE = "did:ted:WgWxqztrNooG92RXvxSTWv" + PATTERN = re.compile(r"did:([a-z]+):((?:[a-zA-Z0-9._-]*:)*[a-zA-Z0-9._-]+)") + + def __init__(self): + """Initializer.""" + + super().__init__( + W3cDID.PATTERN, + error="Value {input} is not a w3c decentralized identifier (DID)", + ) + + +_W3cDID = {"validate": W3cDID(), "example": W3cDID.EXAMPLE} + + +class DIDMatchInfoSchema(OpenAPISchema): + """Path parameters and validators for request taking DID.""" + + did = fields.Str( + description="decentralize identifier(DID)", required=True, **_W3cDID + ) + + +@docs(tags=["resolver"], summary="Retrieve doc for requested did") +@match_info_schema(DIDMatchInfoSchema()) +@response_schema(DIDDocSchema(), 200) +async def resolve_did(request: web.BaseRequest): + """Retrieve a did document.""" + context: AdminRequestContext = request["context"] + + did = request.match_info["did"] + try: + session = await context.session() + resolver = session.inject(DIDResolver) + document = await resolver.resolve(context.profile, did) + result = document.serialize() + except StorageNotFoundError as err: + raise web.HTTPNotFound(reason=err.roll_up) from err + except (BaseModelError, StorageError) as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + return web.json_response(result) + + +async def register(app: web.Application): + """Register routes.""" + + app.add_routes( + [ + web.get( + "/resolver/resolve/{did}", + resolve_did, + allow_head=False, + ), + ] + ) + + +def post_process_routes(app: web.Application): + """Amend swagger API.""" + + # Add top-level tags description + if "tags" not in app._state["swagger_dict"]: + app._state["swagger_dict"]["tags"] = [] + app._state["swagger_dict"]["tags"].append( + { + "name": "resolver", + "description": "universal resolvers", + "externalDocs": {"description": "Specification"}, # , "url": SPEC_URI}, + } + ) diff --git a/aries_cloudagent/resolver/tests/test_did_resolver.py b/aries_cloudagent/resolver/tests/test_did_resolver.py index 315cc7318f..d580a1a94e 100644 --- a/aries_cloudagent/resolver/tests/test_did_resolver.py +++ b/aries_cloudagent/resolver/tests/test_did_resolver.py @@ -181,9 +181,7 @@ def test_match_did_to_resolver_registration_order(): @pytest.mark.asyncio async def test_dereference(resolver, profile): url = "did:example:1234abcd#4" - assert DOC["verificationMethod"][1] == await resolver.dereference( - profile, url - ) + assert DOC["verificationMethod"][1] == await resolver.dereference(profile, url) @pytest.mark.asyncio diff --git a/aries_cloudagent/resolver/tests/test_routes.py b/aries_cloudagent/resolver/tests/test_routes.py new file mode 100644 index 0000000000..7ae322c78e --- /dev/null +++ b/aries_cloudagent/resolver/tests/test_routes.py @@ -0,0 +1,104 @@ +import unittest +import asyncio +import pytest +from asynctest import mock as async_mock +from ...storage.error import StorageError, StorageNotFoundError +from ...messaging.models.base import BaseModelError + +from ..base import ( + BaseDIDResolver, + DIDMethodNotSupported, + DIDNotFound, + ResolverType, +) +from ...resolver.did import DID +from ...resolver.diddoc import ResolvedDIDDoc +from ..did_resolver import DIDResolver +from ..did_resolver_registry import DIDResolverRegistry +from .test_did_resolver import TEST_DID_METHODS +from ...admin.request_context import AdminRequestContext +from .test_diddoc import DOC +from .. import routes as test_module +from ...core.in_memory import InMemoryProfile + +did_doc = ResolvedDIDDoc(DOC) + + +@pytest.fixture +def mock_response(): + json_response = async_mock.MagicMock() + temp_value = test_module.web.json_response + test_module.web.json_response = json_response + yield json_response + test_module.web.json_response = temp_value + + +@pytest.fixture +def mock_resolver(): + did_resolver = async_mock.MagicMock() + did_resolver.resolve = async_mock.CoroutineMock(return_value=did_doc) + yield did_resolver + + +@pytest.fixture +def mock_request(mock_resolver): + context = AdminRequestContext.test_context({DIDResolver: mock_resolver}) + outbound_message_router = async_mock.CoroutineMock() + request_dict = { + "context": context, + "outbound_message_router": outbound_message_router, + } + request = async_mock.MagicMock( + match_info={ + "did": "did:ethr:mainnet:0xb9c5714089478a327f09197987f16f9e5d936e8a", + }, + query={}, + json=async_mock.CoroutineMock(return_value={}), + __getitem__=lambda _, k: request_dict[k], + ) + yield request + + +@pytest.mark.asyncio +async def test_resolver(mock_request, mock_response): + await test_module.resolve_did(mock_request) + mock_response.assert_called_once_with(did_doc.serialize()) + # TODO: test http response codes + + +@pytest.mark.asyncio +async def test_resolver_not_found_error(mock_resolver, mock_request, mock_response): + mock_resolver.resolve = async_mock.CoroutineMock(side_effect=StorageNotFoundError()) + with pytest.raises(test_module.web.HTTPNotFound): + await test_module.resolve_did(mock_request) + + +@pytest.mark.asyncio +async def test_resolver_bad_req_error(mock_resolver, mock_request, mock_response): + mock_resolver.resolve = async_mock.CoroutineMock(side_effect=BaseModelError()) + with pytest.raises(test_module.web.HTTPBadRequest): + await test_module.resolve_did(mock_request) + + +@pytest.mark.asyncio +async def test_resolver_bad_req_storage_error( + mock_resolver, mock_request, mock_response +): + mock_resolver.resolve = async_mock.CoroutineMock(side_effect=StorageError()) + with pytest.raises(test_module.web.HTTPBadRequest): + await test_module.resolve_did(mock_request) + + +@pytest.mark.asyncio +async def test_register(): + mock_app = async_mock.MagicMock() + mock_app.add_routes = async_mock.MagicMock() + await test_module.register(mock_app) + mock_app.add_routes.assert_called_once() + + +@pytest.mark.asyncio +async def test_post_process_routes(): + mock_app = async_mock.MagicMock(_state={"swagger_dict": {}}) + test_module.post_process_routes(mock_app) + assert "tags" in mock_app._state["swagger_dict"]