Skip to content

Commit 1124d32

Browse files
feat: add list endpoint for out-of-band records
Backport of b6c82ac from main. Adds GET /out-of-band/records endpoint to query OobRecord entries with pagination and filtering support. Supports filtering by state, role, connection_id, and invi_msg_id, plus standard pagination parameters (limit, offset, order_by, descending). Closes #4044 Signed-off-by: OpSecId <patrick@opsec.id> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b24a8d5 commit 1124d32

File tree

2 files changed

+183
-1
lines changed

2 files changed

+183
-1
lines changed

acapy_agent/protocols/out_of_band/v1_0/routes.py

Lines changed: 112 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,108 @@ 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 ("connection_id", "invi_msg_id")
320+
if request.query.get(k, "") != ""
321+
}
322+
post_filter = {
323+
k: request.query[k]
324+
for k in ("state", "role")
325+
if request.query.get(k, "") != ""
326+
}
327+
328+
limit, offset, order_by, descending = get_paginated_query_params(request)
329+
330+
profile = context.profile
331+
try:
332+
async with profile.session() as session:
333+
records = await OobRecord.query(
334+
session,
335+
tag_filter,
336+
limit=limit,
337+
offset=offset,
338+
order_by=order_by,
339+
descending=descending,
340+
post_filter_positive=post_filter,
341+
)
342+
results = [record.serialize() for record in records]
343+
except (StorageError, BaseModelError) as err:
344+
raise web.HTTPBadRequest(reason=err.roll_up) from err
345+
346+
return web.json_response({"results": results})
347+
348+
243349
@docs(tags=["out-of-band"], summary="Fetch an existing Out-of-Band invitation.")
244350
@querystring_schema(OobIdQueryStringSchema())
245351
@response_schema(InvitationRecordResponseSchema(), description="")
@@ -414,6 +520,11 @@ async def register(app: web.Application):
414520
[
415521
web.post("/out-of-band/create-invitation", invitation_create),
416522
web.post("/out-of-band/receive-invitation", invitation_receive),
523+
web.get(
524+
"/out-of-band/records",
525+
oob_records_list,
526+
allow_head=False,
527+
),
417528
web.get(
418529
"/out-of-band/invitations",
419530
invitation_fetch,

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from .....admin.request_context import AdminRequestContext
44
from .....connections.models.conn_record import ConnRecord
5+
from .....storage.error import StorageError
56
from .....tests import mock
67
from .....utils.testing import create_test_profile
78
from .. import routes as test_module
@@ -232,6 +233,76 @@ async def test_invitation_receive_x(self):
232233
with self.assertRaises(test_module.web.HTTPBadRequest):
233234
await test_module.invitation_receive(self.request)
234235

236+
async def test_oob_records_list(self):
237+
mock_record = mock.MagicMock(
238+
serialize=mock.MagicMock(return_value={"oob_id": "test"})
239+
)
240+
with (
241+
mock.patch.object(
242+
test_module.OobRecord,
243+
"query",
244+
mock.CoroutineMock(return_value=[mock_record]),
245+
),
246+
mock.patch.object(
247+
test_module.web, "json_response", mock.Mock()
248+
) as mock_json_response,
249+
):
250+
await test_module.oob_records_list(self.request)
251+
mock_json_response.assert_called_once_with(
252+
{"results": [{"oob_id": "test"}]}
253+
)
254+
255+
async def test_oob_records_list_with_filters(self):
256+
self.request.query = {
257+
"state": "initial",
258+
"role": "sender",
259+
"connection_id": "test-conn-id",
260+
"invi_msg_id": "test-invi-id",
261+
}
262+
with (
263+
mock.patch.object(
264+
test_module.OobRecord,
265+
"query",
266+
mock.CoroutineMock(return_value=[]),
267+
) as mock_query,
268+
mock.patch.object(
269+
test_module.web, "json_response", mock.Mock()
270+
) as mock_json_response,
271+
):
272+
await test_module.oob_records_list(self.request)
273+
mock_query.assert_called_once()
274+
call_kwargs = mock_query.call_args
275+
tag_filter = call_kwargs[0][1]
276+
assert "connection_id" in tag_filter
277+
assert "invi_msg_id" in tag_filter
278+
assert "state" not in tag_filter
279+
mock_json_response.assert_called_once_with({"results": []})
280+
281+
async def test_oob_records_list_with_pagination(self):
282+
self.request.query = {"limit": "10", "offset": "5"}
283+
with (
284+
mock.patch.object(
285+
test_module.OobRecord,
286+
"query",
287+
mock.CoroutineMock(return_value=[]),
288+
) as mock_query,
289+
mock.patch.object(test_module.web, "json_response", mock.Mock()),
290+
):
291+
await test_module.oob_records_list(self.request)
292+
mock_query.assert_called_once()
293+
call_kwargs = mock_query.call_args[1]
294+
assert call_kwargs["limit"] == 10
295+
assert call_kwargs["offset"] == 5
296+
297+
async def test_oob_records_list_storage_error(self):
298+
with mock.patch.object(
299+
test_module.OobRecord,
300+
"query",
301+
mock.CoroutineMock(side_effect=StorageError("test error")),
302+
):
303+
with self.assertRaises(test_module.web.HTTPBadRequest):
304+
await test_module.oob_records_list(self.request)
305+
235306
async def test_register(self):
236307
mock_app = mock.MagicMock()
237308
mock_app.add_routes = mock.MagicMock()

0 commit comments

Comments
 (0)