diff --git a/aries_cloudagent/config/argparse.py b/aries_cloudagent/config/argparse.py index 0464dfcf94..c3cc337f6b 100644 --- a/aries_cloudagent/config/argparse.py +++ b/aries_cloudagent/config/argparse.py @@ -620,6 +620,28 @@ def add_arguments(self, parser: ArgumentParser): env_var="ACAPY_READ_ONLY_LEDGER", help="Sets ledger to read-only to prevent updates. Default: false.", ) + parser.add_argument( + "--universal-resolver", + type=str, + nargs="?", + metavar="", + env_var="ACAPY_UNIVERSAL_RESOLVER", + const="DEFAULT", + help="Enable resolution from a universal resolver.", + ) + parser.add_argument( + "--universal-resolver-regex", + type=str, + nargs="+", + metavar="", + env_var="ACAPY_UNIVERSAL_RESOLVER_REGEX", + help=( + "Regex matching DIDs to resolve using the unversal resolver. " + "Multiple can be specified. " + "Defaults to a regex matching all DIDs resolvable by universal " + "resolver instance." + ), + ) def get_settings(self, args: Namespace) -> dict: """Extract general settings.""" @@ -659,6 +681,18 @@ def get_settings(self, args: Namespace) -> dict: if args.read_only_ledger: settings["read_only_ledger"] = True + + if args.universal_resolver_regex and not args.universal_resolver: + raise ArgsParseError( + "--universal-resolver-regex cannot be used without --universal-resolver" + ) + + if args.universal_resolver: + settings["resolver.universal"] = args.universal_resolver + + if args.universal_resolver_regex: + settings["resolver.universal.supported"] = args.universal_resolver_regex + return settings diff --git a/aries_cloudagent/config/tests/test_argparse.py b/aries_cloudagent/config/tests/test_argparse.py index de680bb8f3..e9303b8987 100644 --- a/aries_cloudagent/config/tests/test_argparse.py +++ b/aries_cloudagent/config/tests/test_argparse.py @@ -469,3 +469,47 @@ async def test_discover_features_args(self): assert (["test_goal_code_1", "test_goal_code_2"]) == settings.get( "disclose_goal_code_list" ) + + def test_universal_resolver(self): + """Test universal resolver flags.""" + parser = argparse.create_argument_parser() + group = argparse.GeneralGroup() + group.add_arguments(parser) + + result = parser.parse_args(["-e", "test", "--universal-resolver"]) + settings = group.get_settings(result) + endpoint = settings.get("resolver.universal") + assert endpoint + assert endpoint == "DEFAULT" + + result = parser.parse_args( + ["-e", "test", "--universal-resolver", "https://example.com"] + ) + settings = group.get_settings(result) + endpoint = settings.get("resolver.universal") + assert endpoint + assert endpoint == "https://example.com" + + result = parser.parse_args( + [ + "-e", + "test", + "--universal-resolver", + "https://example.com", + "--universal-resolver-regex", + "regex", + ] + ) + settings = group.get_settings(result) + endpoint = settings.get("resolver.universal") + assert endpoint + assert endpoint == "https://example.com" + supported_regex = settings.get("resolver.universal.supported") + assert supported_regex + assert supported_regex == ["regex"] + + result = parser.parse_args( + ["-e", "test", "--universal-resolver-regex", "regex"] + ) + with self.assertRaises(argparse.ArgsParseError): + group.get_settings(result) diff --git a/aries_cloudagent/resolver/__init__.py b/aries_cloudagent/resolver/__init__.py index d7dceffb03..51bef53657 100644 --- a/aries_cloudagent/resolver/__init__.py +++ b/aries_cloudagent/resolver/__init__.py @@ -37,3 +37,10 @@ async def setup(context: InjectionContext): ).provide(context.settings, context.injector) await web_resolver.setup(context) registry.register(web_resolver) + + if context.settings.get("resolver.universal"): + universal_resolver = ClassProvider( + "aries_cloudagent.resolver.default.universal.UniversalResolver" + ).provide(context.settings, context.injector) + await universal_resolver.setup(context) + registry.register(universal_resolver) diff --git a/aries_cloudagent/resolver/default/tests/test_universal.py b/aries_cloudagent/resolver/default/tests/test_universal.py new file mode 100644 index 0000000000..381e9194e3 --- /dev/null +++ b/aries_cloudagent/resolver/default/tests/test_universal.py @@ -0,0 +1,222 @@ +"""Test universal resolver with http bindings.""" + +import re +from typing import Dict, Union + +from asynctest import mock as async_mock +import pytest + +from aries_cloudagent.config.settings import Settings + +from .. import universal as test_module +from ...base import DIDNotFound, ResolverError +from ..universal import UniversalResolver + + +@pytest.fixture +async def resolver(): + """Resolver fixture.""" + yield UniversalResolver( + endpoint="https://example.com", supported_did_regex=re.compile("^did:sov:.*$") + ) + + +@pytest.fixture +def profile(): + """Profile fixture.""" + yield async_mock.MagicMock() + + +class MockResponse: + """Mock http response.""" + + def __init__(self, status: int, body: Union[str, Dict]): + self.status = status + self.body = body + + async def json(self): + return self.body + + async def text(self): + return self.body + + async def __aenter__(self): + """For use as async context.""" + return self + + async def __aexit__(self, err_type, err_value, err_exc): + """For use as async context.""" + + +class MockClientSession: + """Mock client session.""" + + def __init__(self, response: MockResponse = None): + self.response = response + + def __call__(self): + return self + + async def __aenter__(self): + """For use as async context.""" + return self + + async def __aexit__(self, err_type, err_value, err_exc): + """For use as async context.""" + + def get(self, endpoint): + """Return response.""" + return self.response + + +@pytest.fixture +def mock_client_session(): + temp = test_module.aiohttp.ClientSession + session = MockClientSession() + test_module.aiohttp.ClientSession = session + yield session + test_module.aiohttp.ClientSession = temp + + +@pytest.mark.asyncio +async def test_resolve(profile, resolver, mock_client_session): + mock_client_session.response = MockResponse( + 200, + { + "didDocument": { + "id": "did:example:123", + "@context": "https://www.w3.org/ns/did/v1", + } + }, + ) + doc = await resolver.resolve(profile, "did:sov:WRfXPg8dantKVubE3HX8pw") + assert doc.get("id") == "did:example:123" + + +@pytest.mark.asyncio +async def test_resolve_not_found(profile, resolver, mock_client_session): + mock_client_session.response = MockResponse(404, "Not found") + with pytest.raises(DIDNotFound): + await resolver.resolve(profile, "did:sov:WRfXPg8dantKVubE3HX8pw") + + +@pytest.mark.asyncio +async def test_resolve_unexpeceted_status(profile, resolver, mock_client_session): + mock_client_session.response = MockResponse( + 500, "Server failed to complete request" + ) + with pytest.raises(ResolverError): + await resolver.resolve(profile, "did:sov:WRfXPg8dantKVubE3HX8pw") + + +@pytest.mark.asyncio +async def test_fetch_resolver_props(mock_client_session: MockClientSession): + mock_client_session.response = MockResponse(200, {"test": "json"}) + assert await test_module._fetch_resolver_props("test") == {"test": "json"} + mock_client_session.response = MockResponse(404, "Not found") + with pytest.raises(ResolverError): + await test_module._fetch_resolver_props("test") + + +@pytest.mark.asyncio +async def test_get_supported_did_regex(): + props = {"example": {"http": {"pattern": "match a test string"}}} + with async_mock.patch.object( + test_module, + "_fetch_resolver_props", + async_mock.CoroutineMock(return_value=props), + ): + pattern = await test_module._get_supported_did_regex("test") + assert pattern.fullmatch("match a test string") + + +def test_compile_supported_did_regex(): + patterns = ["one", "two", "three"] + compiled = test_module._compile_supported_did_regex(patterns) + assert compiled.match("one") + assert compiled.match("two") + assert compiled.match("three") + + +@pytest.mark.asyncio +async def test_setup_endpoint_regex_set(resolver: UniversalResolver): + settings = Settings( + { + "resolver.universal": "http://example.com", + "resolver.universal.supported": "test", + } + ) + context = async_mock.MagicMock() + context.settings = settings + with async_mock.patch.object( + test_module, + "_compile_supported_did_regex", + async_mock.MagicMock(return_value="pattern"), + ): + await resolver.setup(context) + + assert resolver._endpoint == "http://example.com" + assert resolver._supported_did_regex == "pattern" + + +@pytest.mark.asyncio +async def test_setup_endpoint_set(resolver: UniversalResolver): + settings = Settings( + { + "resolver.universal": "http://example.com", + } + ) + context = async_mock.MagicMock() + context.settings = settings + with async_mock.patch.object( + test_module, + "_get_supported_did_regex", + async_mock.CoroutineMock(return_value="pattern"), + ): + await resolver.setup(context) + + assert resolver._endpoint == "http://example.com" + assert resolver._supported_did_regex == "pattern" + + +@pytest.mark.asyncio +async def test_setup_endpoint_default(resolver: UniversalResolver): + settings = Settings( + { + "resolver.universal": "DEFAULT", + } + ) + context = async_mock.MagicMock() + context.settings = settings + with async_mock.patch.object( + test_module, + "_get_supported_did_regex", + async_mock.CoroutineMock(return_value="pattern"), + ): + await resolver.setup(context) + + assert resolver._endpoint == test_module.DEFAULT_ENDPOINT + assert resolver._supported_did_regex == "pattern" + + +@pytest.mark.asyncio +async def test_setup_endpoint_unset(resolver: UniversalResolver): + settings = Settings() + context = async_mock.MagicMock() + context.settings = settings + with async_mock.patch.object( + test_module, + "_get_supported_did_regex", + async_mock.CoroutineMock(return_value="pattern"), + ): + await resolver.setup(context) + + assert resolver._endpoint == test_module.DEFAULT_ENDPOINT + assert resolver._supported_did_regex == "pattern" + + +@pytest.mark.asyncio +async def test_supported_did_regex_not_setup(): + resolver = UniversalResolver() + with pytest.raises(ResolverError): + resolver.supported_did_regex diff --git a/aries_cloudagent/resolver/default/universal.py b/aries_cloudagent/resolver/default/universal.py new file mode 100644 index 0000000000..6846d7f016 --- /dev/null +++ b/aries_cloudagent/resolver/default/universal.py @@ -0,0 +1,102 @@ +"""HTTP Universal DID Resolver.""" + +import logging +import re +from typing import Iterable, Optional, Pattern, Union + +import aiohttp + +from ...config.injection_context import InjectionContext +from ...core.profile import Profile +from ..base import BaseDIDResolver, DIDNotFound, ResolverError, ResolverType + +LOGGER = logging.getLogger(__name__) +DEFAULT_ENDPOINT = "https://dev.uniresolver.io" + + +async def _fetch_resolver_props(endpoint: str) -> dict: + """Retrieve universal resolver properties.""" + async with aiohttp.ClientSession() as session: + async with session.get(f"{endpoint}/1.0/properties/") as resp: + if resp.status >= 200 and resp.status < 400: + return await resp.json() + raise ResolverError( + "Failed to retrieve resolver properties: " + await resp.text() + ) + + +async def _get_supported_did_regex(endpoint: str) -> Pattern: + props = await _fetch_resolver_props(endpoint) + return _compile_supported_did_regex( + driver["http"]["pattern"] for driver in props.values() + ) + + +def _compile_supported_did_regex(patterns: Iterable[Union[str, Pattern]]): + """Create regex from list of regex.""" + return re.compile( + "(?:" + + "|".join( + [ + pattern.pattern if isinstance(pattern, Pattern) else pattern + for pattern in patterns + ] + ) + + ")" + ) + + +class UniversalResolver(BaseDIDResolver): + """Universal DID Resolver with HTTP bindings.""" + + def __init__( + self, + *, + endpoint: Optional[str] = None, + supported_did_regex: Optional[Pattern] = None, + ): + """Initialize UniversalResolver.""" + super().__init__(ResolverType.NON_NATIVE) + self._endpoint = endpoint + self._supported_did_regex = supported_did_regex + + async def setup(self, context: InjectionContext): + """Preform setup, populate supported method list, configuration.""" + endpoint = context.settings.get_str("resolver.universal") + if endpoint == "DEFAULT" or not endpoint: + endpoint = DEFAULT_ENDPOINT + + supported = context.settings.get("resolver.universal.supported") + if supported is None: + supported_did_regex = await _get_supported_did_regex(endpoint) + else: + supported_did_regex = _compile_supported_did_regex(supported) + + self._endpoint = endpoint + self._supported_did_regex = supported_did_regex + + @property + def supported_did_regex(self) -> Pattern: + """Return supported methods regex.""" + if not self._supported_did_regex: + raise ResolverError("Resolver has not been set up") + + return self._supported_did_regex + + async def _resolve(self, _profile: Profile, did: str) -> dict: + """Resolve DID through remote universal resolver.""" + + async with aiohttp.ClientSession() as session: + async with session.get(f"{self._endpoint}/1.0/identifiers/{did}") as resp: + if resp.status == 200: + doc = await resp.json() + did_doc = doc["didDocument"] + LOGGER.info("Retrieved doc: %s", did_doc) + return did_doc + if resp.status == 404: + raise DIDNotFound(f"{did} not found by {self.__class__.__name__}") + + text = await resp.text() + raise ResolverError( + f"Unexecpted status from universal resolver ({resp.status}): {text}" + )