Skip to content

Commit a9e271d

Browse files
cl0eteff137
andauthored
💥 Return existing connection to public DID (#1843)
* add reuse param * if reuse find and return conn * 🎨 Rename query param; code cleanup * add tests * update openapi spec * rename to reuse_connection * update unit tests * update e2e tests * update openapi spec * update doc strings * update openapi spec * deduplicate --------- Co-authored-by: ff137 <ff137@proton.me>
1 parent 68dd6e7 commit a9e271d

6 files changed

Lines changed: 273 additions & 63 deletions

File tree

app/routes/connections.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ async def create_did_exchange_request( # noqa: D417
204204
goal: str | None = None,
205205
goal_code: str | None = None,
206206
my_label: str | None = None,
207+
reuse_connection: bool = True,
207208
use_did: str | None = None,
208209
use_did_method: str | None = None,
209210
use_public_did: bool = False,
@@ -213,6 +214,9 @@ async def create_did_exchange_request( # noqa: D417
213214
---
214215
This endpoint allows you to initiate a DID Exchange request with another party using their public DID.
215216
217+
NB: By default, returns existing completed connections with the same `their_public_did` instead of creating
218+
new ones. Set `reuse_connection` to False to disable this behavior.
219+
216220
The goal and goal_code parameters provide additional context for the request.
217221
218222
Only one of `use_did`, `use_did_method` or `use_public_did` should be specified. If none of these are specified,
@@ -230,6 +234,9 @@ async def create_did_exchange_request( # noqa: D417
230234
Optional self-attested code for sharing the intent of the connection.
231235
my_label: str, optional
232236
Your label for the request.
237+
reuse_connection: bool
238+
If a connection with `their_public_did` already exists, return it instead of creating a new one.
239+
Defaults to True.
233240
use_did: str, optional
234241
Your local DID to use for the connection.
235242
use_did_method: str, optional
@@ -250,6 +257,7 @@ async def create_did_exchange_request( # noqa: D417
250257
"goal": goal,
251258
"goal_code": goal_code,
252259
"my_label": my_label,
260+
"reuse_connection": reuse_connection,
253261
"use_did": use_did,
254262
"use_did_method": use_did_method,
255263
"use_public_did": use_public_did,
@@ -258,6 +266,40 @@ async def create_did_exchange_request( # noqa: D417
258266
bound_logger.debug("POST request received: Create DID exchange request")
259267

260268
async with client_from_auth(auth) as aries_controller:
269+
if reuse_connection:
270+
bound_logger.debug(
271+
"Checking for existing connections with the same their_public_did"
272+
)
273+
existing_connections = await handle_acapy_call(
274+
logger=bound_logger,
275+
acapy_call=aries_controller.connection.get_connections,
276+
their_public_did=their_public_did,
277+
)
278+
279+
# Filter for completed connections after DB query for better performance
280+
completed_connections = [
281+
conn
282+
for conn in existing_connections.results
283+
if conn.rfc23_state == "completed"
284+
]
285+
286+
if completed_connections:
287+
bound_logger.debug(
288+
"Found {} completed connection(s) with `their_public_did`: {}",
289+
len(completed_connections),
290+
their_public_did,
291+
)
292+
# Return the first completed connection instead of creating a new one
293+
result = conn_record_to_connection(completed_connections[0])
294+
bound_logger.debug(
295+
"Returning existing completed connection instead of creating new one."
296+
)
297+
return result
298+
else:
299+
bound_logger.debug(
300+
"No existing completed connections found. Creating new one."
301+
)
302+
261303
connection_record = await handle_acapy_call(
262304
logger=bound_logger,
263305
acapy_call=aries_controller.did_exchange.create_request,

app/tests/e2e/test_did_exchange.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ async def test_create_did_exchange_request(
4141
controller=faber_anoncreds_acapy_client
4242
)
4343

44-
request_data = {"their_public_did": faber_public_did.did}
44+
request_data = {
45+
"their_public_did": faber_public_did.did,
46+
"reuse_connection": False,
47+
}
4548

4649
if use_did:
4750
new_did = await acapy_wallet.create_did(

app/tests/e2e/test_did_rotate.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async def test_rotate_did(
2525
controller=faber_anoncreds_acapy_client
2626
)
2727

28-
request_data = {"their_public_did": faber_public_did.did}
28+
request_data = {"their_public_did": faber_public_did.did, "reuse_connection": False}
2929
response = await alice_member_client.post(
3030
f"{CONNECTIONS_BASE_PATH}/did-exchange/create-request", params=request_data
3131
)
@@ -67,7 +67,7 @@ async def test_hangup_did_rotation(
6767
controller=faber_anoncreds_acapy_client
6868
)
6969

70-
request_data = {"their_public_did": faber_public_did.did}
70+
request_data = {"their_public_did": faber_public_did.did, "reuse_connection": False}
7171
response = await alice_member_client.post(
7272
f"{CONNECTIONS_BASE_PATH}/did-exchange/create-request", params=request_data
7373
)

app/tests/routes/connections/test_create_did_exchange_request.py

Lines changed: 206 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,88 @@
2525
)
2626

2727

28+
@pytest.fixture
29+
def mock_aries_controller():
30+
"""Create a mock aries controller with default setup."""
31+
mock_controller = AsyncMock()
32+
mock_controller.did_exchange.create_request = AsyncMock(
33+
return_value=created_connection
34+
)
35+
mock_controller.connection.get_connections = AsyncMock(
36+
return_value=AsyncMock(results=[])
37+
)
38+
return mock_controller
39+
40+
41+
@pytest.fixture
42+
def mock_patches():
43+
"""Setup common patches used across multiple tests."""
44+
with (
45+
patch("app.routes.connections.client_from_auth") as mock_client_from_auth,
46+
patch(
47+
"app.routes.connections.conn_record_to_connection",
48+
return_value=created_connection,
49+
) as mock_conn_record,
50+
):
51+
yield mock_client_from_auth, mock_conn_record
52+
53+
54+
def setup_controller_context(mock_client_from_auth, mock_controller):
55+
"""Setup the controller context for the mock client."""
56+
mock_client_from_auth.return_value.__aenter__.return_value = mock_controller
57+
58+
59+
async def call_create_did_exchange_request(**kwargs):
60+
"""Helper to call create_did_exchange_request with default values."""
61+
defaults = {
62+
"their_public_did": test_their_public_did,
63+
"alias": None,
64+
"goal": None,
65+
"goal_code": None,
66+
"my_label": None,
67+
"reuse_connection": True,
68+
"use_did": None,
69+
"use_did_method": None,
70+
"use_public_did": False,
71+
"auth": "mocked_auth",
72+
}
73+
defaults.update(kwargs)
74+
return await create_did_exchange_request(**defaults)
75+
76+
77+
def assert_get_connections_called(mock_controller, should_be_called=True):
78+
"""Assert whether get_connections was called as expected."""
79+
if should_be_called:
80+
mock_controller.connection.get_connections.assert_awaited_once_with(
81+
their_public_did=test_their_public_did
82+
)
83+
else:
84+
mock_controller.connection.get_connections.assert_not_awaited()
85+
86+
87+
def assert_create_request_called(
88+
mock_controller, should_be_called=True, **expected_params
89+
):
90+
"""Assert whether create_request was called as expected."""
91+
if should_be_called:
92+
defaults = {
93+
"their_public_did": test_their_public_did,
94+
"alias": None,
95+
"auto_accept": True,
96+
"goal": None,
97+
"goal_code": None,
98+
"my_label": None,
99+
"protocol": "didexchange/1.1",
100+
"use_did": None,
101+
"use_did_method": None,
102+
"use_public_did": False,
103+
}
104+
defaults.update(expected_params)
105+
mock_controller.did_exchange.create_request.assert_awaited_once_with(**defaults)
106+
else:
107+
mock_controller.did_exchange.create_request.assert_not_awaited()
108+
109+
28110
@pytest.mark.anyio
29111
@pytest.mark.parametrize(
30112
"body_params, expected_alias, expected_use_did, expected_use_did_method, expected_use_public_did",
@@ -59,52 +141,38 @@ async def test_create_did_exchange_request_success(
59141
expected_use_did,
60142
expected_use_did_method,
61143
expected_use_public_did,
144+
mock_aries_controller,
145+
mock_patches,
62146
):
63-
mock_aries_controller = AsyncMock()
64-
mock_aries_controller.did_exchange.create_request = AsyncMock(
65-
return_value=created_connection
66-
)
147+
mock_client_from_auth, mock_conn_record = mock_patches
148+
setup_controller_context(mock_client_from_auth, mock_aries_controller)
67149

68-
with (
69-
patch("app.routes.connections.client_from_auth") as mock_client_from_auth,
70-
patch(
71-
"app.routes.connections.conn_record_to_connection",
72-
return_value=created_connection,
73-
),
74-
):
75-
mock_client_from_auth.return_value.__aenter__.return_value = (
76-
mock_aries_controller
77-
)
150+
if not body_params:
151+
body_params = {}
78152

79-
if not body_params:
80-
body_params = {}
81-
82-
response = await create_did_exchange_request(
83-
their_public_did=test_their_public_did,
84-
alias=body_params.get("alias"),
85-
goal=body_params.get("goal"),
86-
goal_code=body_params.get("goal_code"),
87-
my_label=body_params.get("my_label"),
88-
use_did=body_params.get("use_did"),
89-
use_did_method=body_params.get("use_did_method"),
90-
use_public_did=body_params.get("use_public_did", False),
91-
auth="mocked_auth",
92-
)
153+
response = await call_create_did_exchange_request(
154+
alias=body_params.get("alias"),
155+
goal=body_params.get("goal"),
156+
goal_code=body_params.get("goal_code"),
157+
my_label=body_params.get("my_label"),
158+
reuse_connection=body_params.get("reuse_connection", True),
159+
use_did=body_params.get("use_did"),
160+
use_did_method=body_params.get("use_did_method"),
161+
use_public_did=body_params.get("use_public_did", False),
162+
)
93163

94-
assert response == created_connection
95-
96-
mock_aries_controller.did_exchange.create_request.assert_awaited_once_with(
97-
their_public_did=test_their_public_did,
98-
alias=expected_alias,
99-
auto_accept=True,
100-
goal=body_params.get("goal"),
101-
goal_code=body_params.get("goal_code"),
102-
my_label=body_params.get("my_label"),
103-
protocol="didexchange/1.1",
104-
use_did=expected_use_did,
105-
use_did_method=expected_use_did_method,
106-
use_public_did=expected_use_public_did,
107-
)
164+
assert response == created_connection
165+
166+
assert_create_request_called(
167+
mock_aries_controller,
168+
alias=expected_alias,
169+
goal=body_params.get("goal"),
170+
goal_code=body_params.get("goal_code"),
171+
my_label=body_params.get("my_label"),
172+
use_did=expected_use_did,
173+
use_did_method=expected_use_did_method,
174+
use_public_did=expected_use_public_did,
175+
)
108176

109177

110178
@pytest.mark.anyio
@@ -117,26 +185,106 @@ async def test_create_did_exchange_request_success(
117185
],
118186
)
119187
async def test_create_did_exchange_request_fail_acapy_error(
120-
exception_class, expected_status_code, expected_detail
188+
exception_class,
189+
expected_status_code,
190+
expected_detail,
191+
mock_aries_controller,
192+
mock_patches,
121193
):
122-
mock_aries_controller = AsyncMock()
194+
mock_client_from_auth, mock_conn_record = mock_patches
123195
mock_aries_controller.did_exchange.create_request = AsyncMock(
124196
side_effect=exception_class(status=expected_status_code, reason=expected_detail)
125197
)
198+
setup_controller_context(mock_client_from_auth, mock_aries_controller)
126199

127-
with (
128-
patch("app.routes.connections.client_from_auth") as mock_client_from_auth,
129-
pytest.raises(HTTPException, match=expected_detail) as exc,
130-
patch("app.routes.connections.conn_record_to_connection"),
131-
):
132-
mock_client_from_auth.return_value.__aenter__.return_value = (
133-
mock_aries_controller
134-
)
135-
136-
await create_did_exchange_request(
137-
their_public_did=test_their_public_did,
138-
alias=None,
139-
auth="mocked_auth",
140-
)
200+
with pytest.raises(HTTPException, match=expected_detail) as exc:
201+
await call_create_did_exchange_request(alias=None)
141202

142203
assert exc.value.status_code == expected_status_code
204+
205+
206+
@pytest.mark.anyio
207+
@pytest.mark.parametrize(
208+
"reuse_connection, existing_connections, should_get_connections, should_create_request, expected_response",
209+
[
210+
# When reuse_connection=True and completed connection exists, return existing
211+
(
212+
True,
213+
[
214+
ConnRecord(
215+
connection_id="existing_id",
216+
state="completed",
217+
rfc23_state="completed",
218+
their_did=test_their_public_did,
219+
)
220+
],
221+
True,
222+
False,
223+
"existing_connection",
224+
),
225+
# When reuse_connection=True but only non-completed connections exist, create new
226+
(
227+
True,
228+
[
229+
ConnRecord(
230+
connection_id="non_completed_id",
231+
state="request-sent",
232+
rfc23_state="request-sent",
233+
their_did=test_their_public_did,
234+
)
235+
],
236+
True,
237+
True,
238+
"new_connection",
239+
),
240+
# When reuse_connection=True but no existing connections, create new
241+
(True, [], True, True, "new_connection"),
242+
# When reuse_connection=False, always create new (don't check existing)
243+
(
244+
False,
245+
[
246+
ConnRecord(
247+
connection_id="existing_id",
248+
state="completed",
249+
rfc23_state="completed",
250+
their_did=test_their_public_did,
251+
)
252+
],
253+
False,
254+
True,
255+
"new_connection",
256+
),
257+
],
258+
)
259+
async def test_create_did_exchange_request_reuse_scenarios(
260+
reuse_connection,
261+
existing_connections,
262+
should_get_connections,
263+
should_create_request,
264+
expected_response,
265+
mock_aries_controller,
266+
mock_patches,
267+
):
268+
"""Test various scenarios for connection reuse behavior."""
269+
mock_client_from_auth, mock_conn_record = mock_patches
270+
271+
# Setup existing connections
272+
mock_aries_controller.connection.get_connections = AsyncMock(
273+
return_value=AsyncMock(results=existing_connections)
274+
)
275+
276+
# Determine expected return value
277+
if expected_response == "existing_connection" and existing_connections:
278+
expected_connection = existing_connections[0]
279+
mock_conn_record.return_value = expected_connection
280+
else:
281+
expected_connection = created_connection
282+
mock_conn_record.return_value = created_connection
283+
284+
setup_controller_context(mock_client_from_auth, mock_aries_controller)
285+
286+
response = await call_create_did_exchange_request(reuse_connection=reuse_connection)
287+
288+
assert response == expected_connection
289+
assert_get_connections_called(mock_aries_controller, should_get_connections)
290+
assert_create_request_called(mock_aries_controller, should_create_request)

0 commit comments

Comments
 (0)