Skip to content

Commit f8abeb5

Browse files
authored
Merge pull request #4046 from kukgini/feat/oob-list-endpoint
feat: add list endpoint for out-of-band records
2 parents 21b25ee + f6263c0 commit f8abeb5

File tree

2 files changed

+191
-1
lines changed

2 files changed

+191
-1
lines changed

acapy_agent/protocols/out_of_band/v1_0/routes.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
from ....admin.request_context import AdminRequestContext
1919
from ....messaging.models.base import BaseModelError
2020
from ....messaging.models.openapi import OpenAPISchema
21+
from ....messaging.models.paginated_query import (
22+
PaginatedQuerySchema,
23+
get_paginated_query_params,
24+
)
2125
from ....messaging.valid import UUID4_EXAMPLE, UUID4_VALIDATE
2226
from ....storage.error import StorageError, StorageNotFoundError
2327
from ...didcomm_prefix import DIDCommPrefix
@@ -26,7 +30,7 @@
2630
from .message_types import SPEC_URI
2731
from .messages.invitation import HSProto, InvitationMessage, InvitationMessageSchema
2832
from .models.invitation import InvitationRecordSchema
29-
from .models.oob_record import OobRecordSchema
33+
from .models.oob_record import OobRecord, OobRecordSchema
3034

3135
LOGGER = logging.getLogger(__name__)
3236

@@ -240,6 +244,106 @@ class OobInvitationRecordMatchInfoSchema(OpenAPISchema):
240244
)
241245

242246

247+
class OobRecordListQueryStringSchema(PaginatedQuerySchema):
248+
"""Parameters and validators for OOB record list request query string."""
249+
250+
state = fields.Str(
251+
required=False,
252+
validate=validate.OneOf(
253+
OobRecord.get_attributes_by_prefix("STATE_", walk_mro=True)
254+
),
255+
metadata={
256+
"description": "OOB record state",
257+
"example": OobRecord.STATE_INITIAL,
258+
},
259+
)
260+
role = fields.Str(
261+
required=False,
262+
validate=validate.OneOf(
263+
OobRecord.get_attributes_by_prefix("ROLE_", walk_mro=False)
264+
),
265+
metadata={
266+
"description": "OOB record role",
267+
"example": OobRecord.ROLE_SENDER,
268+
},
269+
)
270+
connection_id = fields.Str(
271+
required=False,
272+
validate=UUID4_VALIDATE,
273+
metadata={
274+
"description": "Connection identifier",
275+
"example": UUID4_EXAMPLE,
276+
},
277+
)
278+
invi_msg_id = fields.Str(
279+
required=False,
280+
validate=UUID4_VALIDATE,
281+
metadata={
282+
"description": "Invitation message identifier",
283+
"example": UUID4_EXAMPLE,
284+
},
285+
)
286+
287+
288+
class OobRecordListSchema(OpenAPISchema):
289+
"""Result schema for OOB record list."""
290+
291+
results = fields.List(
292+
fields.Nested(OobRecordSchema()),
293+
required=True,
294+
metadata={"description": "List of OOB records"},
295+
)
296+
297+
298+
@docs(
299+
tags=["out-of-band"],
300+
summary="Query OOB records",
301+
)
302+
@querystring_schema(OobRecordListQueryStringSchema())
303+
@response_schema(OobRecordListSchema(), 200, description="")
304+
@tenant_authentication
305+
async def oob_records_list(request: web.BaseRequest):
306+
"""Request handler for searching OOB records.
307+
308+
Args:
309+
request: aiohttp request object
310+
311+
Returns:
312+
The OOB record list response
313+
314+
"""
315+
context: AdminRequestContext = request["context"]
316+
317+
tag_filter = {
318+
k: request.query[k]
319+
for k in ("state", "connection_id", "invi_msg_id")
320+
if request.query.get(k, "") != ""
321+
}
322+
post_filter = {
323+
k: request.query[k] for k in ("role",) if request.query.get(k, "") != ""
324+
}
325+
326+
limit, offset, order_by, descending = get_paginated_query_params(request)
327+
328+
profile = context.profile
329+
try:
330+
async with profile.session() as session:
331+
records = await OobRecord.query(
332+
session,
333+
tag_filter,
334+
limit=limit,
335+
offset=offset,
336+
order_by=order_by,
337+
descending=descending,
338+
post_filter_positive=post_filter,
339+
)
340+
results = [record.serialize() for record in records]
341+
except (StorageError, BaseModelError) as err:
342+
raise web.HTTPBadRequest(reason=err.roll_up) from err
343+
344+
return web.json_response({"results": results})
345+
346+
243347
@docs(tags=["out-of-band"], summary="Fetch an existing Out-of-Band invitation.")
244348
@querystring_schema(OobIdQueryStringSchema())
245349
@response_schema(InvitationRecordResponseSchema(), description="")
@@ -413,6 +517,11 @@ async def register(app: web.Application):
413517
[
414518
web.post("/out-of-band/create-invitation", invitation_create),
415519
web.post("/out-of-band/receive-invitation", invitation_receive),
520+
web.get(
521+
"/out-of-band/records",
522+
oob_records_list,
523+
allow_head=False,
524+
),
416525
web.get(
417526
"/out-of-band/invitations",
418527
invitation_fetch,

acapy_agent/protocols/out_of_band/v1_0/tests/test_routes.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,87 @@ async def test_invitation_create(self):
7575
)
7676
mock_json_response.assert_called_once_with({"abc": "123"})
7777

78+
async def test_oob_records_list(self):
79+
with (
80+
mock.patch.object(
81+
test_module.OobRecord, "query", mock.CoroutineMock()
82+
) as mock_query,
83+
mock.patch.object(
84+
test_module.web, "json_response", mock.Mock()
85+
) as mock_json_response,
86+
):
87+
mock_query.return_value = [
88+
mock.MagicMock(serialize=mock.MagicMock(return_value={"oob_id": "1"})),
89+
mock.MagicMock(serialize=mock.MagicMock(return_value={"oob_id": "2"})),
90+
]
91+
92+
await test_module.oob_records_list(self.request)
93+
mock_query.assert_called_once()
94+
mock_json_response.assert_called_once_with(
95+
{"results": [{"oob_id": "1"}, {"oob_id": "2"}]}
96+
)
97+
98+
async def test_oob_records_list_with_filters(self):
99+
self.request.query = {
100+
"state": "initial",
101+
"role": "sender",
102+
"connection_id": "test-conn-id",
103+
"invi_msg_id": "test-invi-id",
104+
}
105+
106+
with (
107+
mock.patch.object(
108+
test_module.OobRecord, "query", mock.CoroutineMock()
109+
) as mock_query,
110+
mock.patch.object(
111+
test_module.web, "json_response", mock.Mock()
112+
) as mock_json_response,
113+
):
114+
mock_query.return_value = []
115+
116+
await test_module.oob_records_list(self.request)
117+
mock_query.assert_called_once()
118+
call_kwargs = mock_query.call_args
119+
# tag_filter is the second positional arg
120+
tag_filter = call_kwargs[0][1]
121+
assert tag_filter == {
122+
"state": "initial",
123+
"connection_id": "test-conn-id",
124+
"invi_msg_id": "test-invi-id",
125+
}
126+
assert call_kwargs[1]["post_filter_positive"] == {
127+
"role": "sender",
128+
}
129+
mock_json_response.assert_called_once_with({"results": []})
130+
131+
async def test_oob_records_list_with_pagination(self):
132+
self.request.query = {
133+
"limit": "10",
134+
"offset": "5",
135+
}
136+
137+
with (
138+
mock.patch.object(
139+
test_module.OobRecord, "query", mock.CoroutineMock()
140+
) as mock_query,
141+
mock.patch.object(test_module.web, "json_response", mock.Mock()),
142+
):
143+
mock_query.return_value = []
144+
145+
await test_module.oob_records_list(self.request)
146+
call_kwargs = mock_query.call_args[1]
147+
assert call_kwargs["limit"] == 10
148+
assert call_kwargs["offset"] == 5
149+
150+
async def test_oob_records_list_storage_error(self):
151+
with mock.patch.object(
152+
test_module.OobRecord,
153+
"query",
154+
mock.CoroutineMock(side_effect=test_module.StorageError("test error")),
155+
):
156+
with self.assertRaises(test_module.web.HTTPBadRequest):
157+
await test_module.oob_records_list(self.request)
158+
78159
async def test_invitation_fetch(self):
79160
self.request.query = {"oob_id": "dummy"}
80161

0 commit comments

Comments
 (0)