Skip to content

Commit 4b1fa13

Browse files
authored
Merge pull request openwallet-foundation#1866 from sicpa-dlab/feature/universal-resolver
feat: add universal resolver
2 parents 2fb70f3 + ff2f106 commit 4b1fa13

5 files changed

Lines changed: 409 additions & 0 deletions

File tree

aries_cloudagent/config/argparse.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,28 @@ def add_arguments(self, parser: ArgumentParser):
620620
env_var="ACAPY_READ_ONLY_LEDGER",
621621
help="Sets ledger to read-only to prevent updates. Default: false.",
622622
)
623+
parser.add_argument(
624+
"--universal-resolver",
625+
type=str,
626+
nargs="?",
627+
metavar="<universal_resolver_endpoint>",
628+
env_var="ACAPY_UNIVERSAL_RESOLVER",
629+
const="DEFAULT",
630+
help="Enable resolution from a universal resolver.",
631+
)
632+
parser.add_argument(
633+
"--universal-resolver-regex",
634+
type=str,
635+
nargs="+",
636+
metavar="<did_regex>",
637+
env_var="ACAPY_UNIVERSAL_RESOLVER_REGEX",
638+
help=(
639+
"Regex matching DIDs to resolve using the unversal resolver. "
640+
"Multiple can be specified. "
641+
"Defaults to a regex matching all DIDs resolvable by universal "
642+
"resolver instance."
643+
),
644+
)
623645

624646
def get_settings(self, args: Namespace) -> dict:
625647
"""Extract general settings."""
@@ -659,6 +681,18 @@ def get_settings(self, args: Namespace) -> dict:
659681

660682
if args.read_only_ledger:
661683
settings["read_only_ledger"] = True
684+
685+
if args.universal_resolver_regex and not args.universal_resolver:
686+
raise ArgsParseError(
687+
"--universal-resolver-regex cannot be used without --universal-resolver"
688+
)
689+
690+
if args.universal_resolver:
691+
settings["resolver.universal"] = args.universal_resolver
692+
693+
if args.universal_resolver_regex:
694+
settings["resolver.universal.supported"] = args.universal_resolver_regex
695+
662696
return settings
663697

664698

aries_cloudagent/config/tests/test_argparse.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,3 +469,47 @@ async def test_discover_features_args(self):
469469
assert (["test_goal_code_1", "test_goal_code_2"]) == settings.get(
470470
"disclose_goal_code_list"
471471
)
472+
473+
def test_universal_resolver(self):
474+
"""Test universal resolver flags."""
475+
parser = argparse.create_argument_parser()
476+
group = argparse.GeneralGroup()
477+
group.add_arguments(parser)
478+
479+
result = parser.parse_args(["-e", "test", "--universal-resolver"])
480+
settings = group.get_settings(result)
481+
endpoint = settings.get("resolver.universal")
482+
assert endpoint
483+
assert endpoint == "DEFAULT"
484+
485+
result = parser.parse_args(
486+
["-e", "test", "--universal-resolver", "https://example.com"]
487+
)
488+
settings = group.get_settings(result)
489+
endpoint = settings.get("resolver.universal")
490+
assert endpoint
491+
assert endpoint == "https://example.com"
492+
493+
result = parser.parse_args(
494+
[
495+
"-e",
496+
"test",
497+
"--universal-resolver",
498+
"https://example.com",
499+
"--universal-resolver-regex",
500+
"regex",
501+
]
502+
)
503+
settings = group.get_settings(result)
504+
endpoint = settings.get("resolver.universal")
505+
assert endpoint
506+
assert endpoint == "https://example.com"
507+
supported_regex = settings.get("resolver.universal.supported")
508+
assert supported_regex
509+
assert supported_regex == ["regex"]
510+
511+
result = parser.parse_args(
512+
["-e", "test", "--universal-resolver-regex", "regex"]
513+
)
514+
with self.assertRaises(argparse.ArgsParseError):
515+
group.get_settings(result)

aries_cloudagent/resolver/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,10 @@ async def setup(context: InjectionContext):
3737
).provide(context.settings, context.injector)
3838
await web_resolver.setup(context)
3939
registry.register(web_resolver)
40+
41+
if context.settings.get("resolver.universal"):
42+
universal_resolver = ClassProvider(
43+
"aries_cloudagent.resolver.default.universal.UniversalResolver"
44+
).provide(context.settings, context.injector)
45+
await universal_resolver.setup(context)
46+
registry.register(universal_resolver)
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
"""Test universal resolver with http bindings."""
2+
3+
import re
4+
from typing import Dict, Union
5+
6+
from asynctest import mock as async_mock
7+
import pytest
8+
9+
from aries_cloudagent.config.settings import Settings
10+
11+
from .. import universal as test_module
12+
from ...base import DIDNotFound, ResolverError
13+
from ..universal import UniversalResolver
14+
15+
16+
@pytest.fixture
17+
async def resolver():
18+
"""Resolver fixture."""
19+
yield UniversalResolver(
20+
endpoint="https://example.com", supported_did_regex=re.compile("^did:sov:.*$")
21+
)
22+
23+
24+
@pytest.fixture
25+
def profile():
26+
"""Profile fixture."""
27+
yield async_mock.MagicMock()
28+
29+
30+
class MockResponse:
31+
"""Mock http response."""
32+
33+
def __init__(self, status: int, body: Union[str, Dict]):
34+
self.status = status
35+
self.body = body
36+
37+
async def json(self):
38+
return self.body
39+
40+
async def text(self):
41+
return self.body
42+
43+
async def __aenter__(self):
44+
"""For use as async context."""
45+
return self
46+
47+
async def __aexit__(self, err_type, err_value, err_exc):
48+
"""For use as async context."""
49+
50+
51+
class MockClientSession:
52+
"""Mock client session."""
53+
54+
def __init__(self, response: MockResponse = None):
55+
self.response = response
56+
57+
def __call__(self):
58+
return self
59+
60+
async def __aenter__(self):
61+
"""For use as async context."""
62+
return self
63+
64+
async def __aexit__(self, err_type, err_value, err_exc):
65+
"""For use as async context."""
66+
67+
def get(self, endpoint):
68+
"""Return response."""
69+
return self.response
70+
71+
72+
@pytest.fixture
73+
def mock_client_session():
74+
temp = test_module.aiohttp.ClientSession
75+
session = MockClientSession()
76+
test_module.aiohttp.ClientSession = session
77+
yield session
78+
test_module.aiohttp.ClientSession = temp
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_resolve(profile, resolver, mock_client_session):
83+
mock_client_session.response = MockResponse(
84+
200,
85+
{
86+
"didDocument": {
87+
"id": "did:example:123",
88+
"@context": "https://www.w3.org/ns/did/v1",
89+
}
90+
},
91+
)
92+
doc = await resolver.resolve(profile, "did:sov:WRfXPg8dantKVubE3HX8pw")
93+
assert doc.get("id") == "did:example:123"
94+
95+
96+
@pytest.mark.asyncio
97+
async def test_resolve_not_found(profile, resolver, mock_client_session):
98+
mock_client_session.response = MockResponse(404, "Not found")
99+
with pytest.raises(DIDNotFound):
100+
await resolver.resolve(profile, "did:sov:WRfXPg8dantKVubE3HX8pw")
101+
102+
103+
@pytest.mark.asyncio
104+
async def test_resolve_unexpeceted_status(profile, resolver, mock_client_session):
105+
mock_client_session.response = MockResponse(
106+
500, "Server failed to complete request"
107+
)
108+
with pytest.raises(ResolverError):
109+
await resolver.resolve(profile, "did:sov:WRfXPg8dantKVubE3HX8pw")
110+
111+
112+
@pytest.mark.asyncio
113+
async def test_fetch_resolver_props(mock_client_session: MockClientSession):
114+
mock_client_session.response = MockResponse(200, {"test": "json"})
115+
assert await test_module._fetch_resolver_props("test") == {"test": "json"}
116+
mock_client_session.response = MockResponse(404, "Not found")
117+
with pytest.raises(ResolverError):
118+
await test_module._fetch_resolver_props("test")
119+
120+
121+
@pytest.mark.asyncio
122+
async def test_get_supported_did_regex():
123+
props = {"example": {"http": {"pattern": "match a test string"}}}
124+
with async_mock.patch.object(
125+
test_module,
126+
"_fetch_resolver_props",
127+
async_mock.CoroutineMock(return_value=props),
128+
):
129+
pattern = await test_module._get_supported_did_regex("test")
130+
assert pattern.fullmatch("match a test string")
131+
132+
133+
def test_compile_supported_did_regex():
134+
patterns = ["one", "two", "three"]
135+
compiled = test_module._compile_supported_did_regex(patterns)
136+
assert compiled.match("one")
137+
assert compiled.match("two")
138+
assert compiled.match("three")
139+
140+
141+
@pytest.mark.asyncio
142+
async def test_setup_endpoint_regex_set(resolver: UniversalResolver):
143+
settings = Settings(
144+
{
145+
"resolver.universal": "http://example.com",
146+
"resolver.universal.supported": "test",
147+
}
148+
)
149+
context = async_mock.MagicMock()
150+
context.settings = settings
151+
with async_mock.patch.object(
152+
test_module,
153+
"_compile_supported_did_regex",
154+
async_mock.MagicMock(return_value="pattern"),
155+
):
156+
await resolver.setup(context)
157+
158+
assert resolver._endpoint == "http://example.com"
159+
assert resolver._supported_did_regex == "pattern"
160+
161+
162+
@pytest.mark.asyncio
163+
async def test_setup_endpoint_set(resolver: UniversalResolver):
164+
settings = Settings(
165+
{
166+
"resolver.universal": "http://example.com",
167+
}
168+
)
169+
context = async_mock.MagicMock()
170+
context.settings = settings
171+
with async_mock.patch.object(
172+
test_module,
173+
"_get_supported_did_regex",
174+
async_mock.CoroutineMock(return_value="pattern"),
175+
):
176+
await resolver.setup(context)
177+
178+
assert resolver._endpoint == "http://example.com"
179+
assert resolver._supported_did_regex == "pattern"
180+
181+
182+
@pytest.mark.asyncio
183+
async def test_setup_endpoint_default(resolver: UniversalResolver):
184+
settings = Settings(
185+
{
186+
"resolver.universal": "DEFAULT",
187+
}
188+
)
189+
context = async_mock.MagicMock()
190+
context.settings = settings
191+
with async_mock.patch.object(
192+
test_module,
193+
"_get_supported_did_regex",
194+
async_mock.CoroutineMock(return_value="pattern"),
195+
):
196+
await resolver.setup(context)
197+
198+
assert resolver._endpoint == test_module.DEFAULT_ENDPOINT
199+
assert resolver._supported_did_regex == "pattern"
200+
201+
202+
@pytest.mark.asyncio
203+
async def test_setup_endpoint_unset(resolver: UniversalResolver):
204+
settings = Settings()
205+
context = async_mock.MagicMock()
206+
context.settings = settings
207+
with async_mock.patch.object(
208+
test_module,
209+
"_get_supported_did_regex",
210+
async_mock.CoroutineMock(return_value="pattern"),
211+
):
212+
await resolver.setup(context)
213+
214+
assert resolver._endpoint == test_module.DEFAULT_ENDPOINT
215+
assert resolver._supported_did_regex == "pattern"
216+
217+
218+
@pytest.mark.asyncio
219+
async def test_supported_did_regex_not_setup():
220+
resolver = UniversalResolver()
221+
with pytest.raises(ResolverError):
222+
resolver.supported_did_regex

0 commit comments

Comments
 (0)