Skip to content

Commit 62db5ba

Browse files
committed
server base: Add PagingMetadata to APIResponse
Previously the cursor value for the paging_metadata of a paginated response was passed directly as `Optional[int]` to the constructor of `APIResponse`. The presence of the `cursor` attribute also controlled if the response contained a `paging_metadata`. This is not conform to the [spec] which requires the `paging_metadata` to be present in every paginated response. In case the response contains all remaining results, the `cursor` value inside must be omitted. This changes correct the behavior for `JSONResponse` to follow the [spec]. As the `PagingMetadata` might carry additional information in the future, a new class was used to pass all metadata from the `_get_slice(...)` method to the resulting `APIResponse`. For `XMLResponse` we mimic the previously implemented encoding of the `cursor` value. Fixes #519 [spec]: https://industrialdigitaltwin.io/aas-specifications/IDTA-01002/v3.1.2/http-rest-api/http-rest-api.html#pagination
1 parent 155c69e commit 62db5ba

3 files changed

Lines changed: 76 additions & 49 deletions

File tree

server/app/interfaces/base.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -146,31 +146,37 @@ def __init__(self, success: bool, messages: Optional[List[Message]] = None):
146146
ResponseData = Union[Result, object, List[object]]
147147

148148

149+
class PagingMetadata:
150+
def __init__(self, cursor: Optional[str] = None):
151+
self.cursor = cursor
152+
153+
149154
class APIResponse(abc.ABC, Response):
150155
@abc.abstractmethod
151156
def __init__(
152-
self, obj: Optional[ResponseData] = None, cursor: Optional[int] = None, stripped: bool = False, *args, **kwargs
157+
self, obj: Optional[ResponseData] = None, paging_metadata: Optional[PagingMetadata] = None,
158+
stripped: bool = False, *args, **kwargs
153159
):
154160
super().__init__(*args, **kwargs)
155161
if obj is None:
156162
self.status_code = 204
157163
else:
158-
self.data = self.serialize(obj, cursor, stripped)
164+
self.data = self.serialize(obj, paging_metadata, stripped)
159165

160166
@abc.abstractmethod
161-
def serialize(self, obj: ResponseData, cursor: Optional[int], stripped: bool) -> str:
167+
def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata], stripped: bool) -> str:
162168
pass
163169

164170

165171
class JsonResponse(APIResponse):
166172
def __init__(self, *args, content_type="application/json", **kwargs):
167173
super().__init__(*args, **kwargs, content_type=content_type)
168174

169-
def serialize(self, obj: ResponseData, cursor: Optional[int], stripped: bool) -> str:
170-
if cursor is None:
175+
def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata], stripped: bool) -> str:
176+
if paging_metadata is None:
171177
data = obj
172178
else:
173-
data = {"paging_metadata": {"cursor": str(cursor)}, "result": obj}
179+
data = {"paging_metadata": paging_metadata, "result": obj}
174180
return json.dumps(
175181
data, cls=StrippedResultToJsonEncoder if stripped else ResultToJsonEncoder, separators=(",", ":")
176182
)
@@ -180,10 +186,10 @@ class XmlResponse(APIResponse):
180186
def __init__(self, *args, content_type="application/xml", **kwargs):
181187
super().__init__(*args, **kwargs, content_type=content_type)
182188

183-
def serialize(self, obj: ResponseData, cursor: Optional[int], stripped: bool) -> str:
189+
def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata], stripped: bool) -> str:
184190
root_elem = etree.Element("response", nsmap=XML_NS_MAP)
185-
if cursor is not None or not (isinstance(obj, list) and not obj):
186-
root_elem.set("cursor", str(cursor))
191+
if paging_metadata is not None:
192+
root_elem.set("cursor", str(paging_metadata.cursor))
187193
if isinstance(obj, Result):
188194
result_elem = self.result_to_xml(obj, **XML_NS_MAP)
189195
for child in result_elem:
@@ -251,13 +257,22 @@ def _message_to_json(cls, message: Message) -> Dict[str, object]:
251257
"timestamp": message.timestamp.isoformat(),
252258
}
253259

260+
@classmethod
261+
def _paging_metadata_to_json(cls, metadata: PagingMetadata) -> Dict[str, object]:
262+
json_result: Dict[str, object] = dict()
263+
if metadata.cursor is not None:
264+
json_result["cursor"] = str(metadata.cursor)
265+
return json_result
266+
254267
def default(self, obj: object) -> object:
255268
if isinstance(obj, Result):
256269
return self._result_to_json(obj)
257270
if isinstance(obj, Message):
258271
return self._message_to_json(obj)
259272
if isinstance(obj, MessageType):
260273
return str(obj)
274+
if isinstance(obj, PagingMetadata):
275+
return self._paging_metadata_to_json(obj)
261276
return super().default(obj)
262277

263278

@@ -274,7 +289,7 @@ def __call__(self, environ, start_response) -> Iterable[bytes]:
274289
return response(environ, start_response)
275290

276291
@classmethod
277-
def _get_slice(cls, request: Request, iterator: Iterable[T]) -> Tuple[Iterator[T], Optional[int]]:
292+
def _get_slice(cls, request: Request, iterator: Iterable[T]) -> Tuple[Iterator[T], Optional[PagingMetadata]]:
278293
limit_str = request.args.get("limit", default="100")
279294
cursor_str = request.args.get("cursor", default="1")
280295
try:
@@ -287,8 +302,14 @@ def _get_slice(cls, request: Request, iterator: Iterable[T]) -> Tuple[Iterator[T
287302
items = list(itertools.islice(iterator, start_index, end_index + 1))
288303
has_more = len(items) > limit
289304
paginated_slice = iter(items[:limit])
290-
next_cursor = cursor + limit + 1 if has_more else None
291-
return paginated_slice, next_cursor
305+
next_cursor = str(cursor + limit + 1) if has_more else None
306+
307+
if next_cursor is not None or cursor > 0:
308+
# add metadata if cursor was present in request
309+
paging_metadata = PagingMetadata(cursor=next_cursor)
310+
else:
311+
paging_metadata = None
312+
return paginated_slice, paging_metadata
292313

293314
def handle_request(self, request: Request):
294315
map_adapter: MapAdapter = self.url_map.bind_to_environ(request.environ)

server/app/interfaces/registry.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from werkzeug.wrappers import Request, Response
1717

1818
import app.model as server_model
19-
from app.interfaces.base import APIResponse, HTTPApiDecoder, ObjectStoreWSGIApp, is_stripped_request
19+
from app.interfaces.base import APIResponse, HTTPApiDecoder, ObjectStoreWSGIApp, is_stripped_request, PagingMetadata
2020
from app.model import DictDescriptorStore
2121
from app.util.converters import IdentifierToBase64URLConverter, base64url_decode
2222

@@ -108,7 +108,7 @@ def __init__(self, object_store: model.AbstractObjectStore, base_path: str = "/a
108108

109109
def _get_all_aas_descriptors(
110110
self, request: "Request"
111-
) -> Tuple[Iterator[server_model.AssetAdministrationShellDescriptor], Optional[int]]:
111+
) -> Tuple[Iterator[server_model.AssetAdministrationShellDescriptor], Optional[PagingMetadata]]:
112112

113113
descriptors: Iterator[server_model.AssetAdministrationShellDescriptor] = self._get_all_obj_of_type(
114114
server_model.AssetAdministrationShellDescriptor
@@ -134,20 +134,20 @@ def _get_all_aas_descriptors(
134134
raise BadRequest(f"Invalid assetType: '{asset_type}'")
135135
descriptors = filter(lambda desc: desc.asset_type == asset_type, descriptors)
136136

137-
paginated_descriptors, end_index = self._get_slice(request, descriptors)
138-
return paginated_descriptors, end_index
137+
paginated_descriptors, paging_metadata = self._get_slice(request, descriptors)
138+
return paginated_descriptors, paging_metadata
139139

140140
def _get_aas_descriptor(self, url_args: Dict) -> server_model.AssetAdministrationShellDescriptor:
141141
return self._get_obj_ts(url_args["aas_id"], server_model.AssetAdministrationShellDescriptor)
142142

143143
def _get_all_submodel_descriptors(self, request: Request) -> Tuple[
144-
Iterator[server_model.SubmodelDescriptor], Optional[int]
144+
Iterator[server_model.SubmodelDescriptor], Optional[PagingMetadata]
145145
]:
146146
submodel_descriptors: Iterator[server_model.SubmodelDescriptor] = self._get_all_obj_of_type(
147147
server_model.SubmodelDescriptor
148148
)
149-
paginated_submodel_descriptors, end_index = self._get_slice(request, submodel_descriptors)
150-
return paginated_submodel_descriptors, end_index
149+
paginated_submodel_descriptors, paging_metadata = self._get_slice(request, submodel_descriptors)
150+
return paginated_submodel_descriptors, paging_metadata
151151

152152
def _get_submodel_descriptor(self, url_args: Dict) -> server_model.SubmodelDescriptor:
153153
return self._get_obj_ts(url_args["submodel_id"], server_model.SubmodelDescriptor)
@@ -170,8 +170,8 @@ def get_self_description(
170170
def get_all_aas_descriptors(
171171
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs
172172
) -> Response:
173-
aas_descriptors, cursor = self._get_all_aas_descriptors(request)
174-
return response_t(list(aas_descriptors), cursor=cursor)
173+
aas_descriptors, paging_metadata = self._get_all_aas_descriptors(request)
174+
return response_t(list(aas_descriptors), paging_metadata=paging_metadata)
175175

176176
def post_aas_descriptor(
177177
self, request: Request, url_args: Dict, response_t: Type[APIResponse], map_adapter: MapAdapter
@@ -301,8 +301,10 @@ def delete_submodel_descriptor_by_id_through_superpath(
301301
def get_all_submodel_descriptors(
302302
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs
303303
) -> Response:
304-
submodel_descriptors, cursor = self._get_all_submodel_descriptors(request)
305-
return response_t(list(submodel_descriptors), cursor=cursor, stripped=is_stripped_request(request))
304+
submodel_descriptors, paging_metadata = self._get_all_submodel_descriptors(request)
305+
return response_t(
306+
list(submodel_descriptors), paging_metadata=paging_metadata, stripped=is_stripped_request(request)
307+
)
306308

307309
def get_submodel_descriptor_by_id(
308310
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs

server/app/interfaces/repository.py

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from werkzeug.exceptions import BadRequest, Conflict, NotFound
2323
from werkzeug.routing import MapAdapter, Rule, Submount
2424

25-
from app.interfaces.base import APIResponse, HTTPApiDecoder, ObjectStoreWSGIApp, T, is_stripped_request
25+
from app.interfaces.base import APIResponse, HTTPApiDecoder, ObjectStoreWSGIApp, T, is_stripped_request, PagingMetadata
2626
from app.util.converters import IdentifierToBase64URLConverter, IdShortPathConverter, base64url_decode
2727
from .base import (ObjectStoreWSGIApp, APIResponse, is_stripped_request, HTTPApiDecoder, T,
2828
ServiceSpecificationProfileEnum, ServiceDescription)
@@ -428,7 +428,9 @@ def _get_submodel_reference(
428428
return ref
429429
raise NotFound(f"The AAS {aas!r} doesn't have a submodel reference to {submodel_id!r}!")
430430

431-
def _get_shells(self, request: Request) -> Tuple[Iterator[model.AssetAdministrationShell], Optional[int]]:
431+
def _get_shells(
432+
self, request: Request
433+
) -> Tuple[Iterator[model.AssetAdministrationShell], Optional[PagingMetadata]]:
432434
aas: Iterator[model.AssetAdministrationShell] = self._get_all_obj_of_type(model.AssetAdministrationShell)
433435

434436
id_short = request.args.get("idShort")
@@ -471,13 +473,13 @@ def _get_shells(self, request: Request) -> Tuple[Iterator[model.AssetAdministrat
471473
aas,
472474
)
473475

474-
paginated_aas, end_index = self._get_slice(request, aas)
475-
return paginated_aas, end_index
476+
paginated_aas, paging_metadata = self._get_slice(request, aas)
477+
return paginated_aas, paging_metadata
476478

477479
def _get_shell(self, url_args: Dict) -> model.AssetAdministrationShell:
478480
return self._get_obj_ts(url_args["aas_id"], model.AssetAdministrationShell)
479481

480-
def _get_submodels(self, request: Request) -> Tuple[Iterator[model.Submodel], Optional[int]]:
482+
def _get_submodels(self, request: Request) -> Tuple[Iterator[model.Submodel], Optional[PagingMetadata]]:
481483
submodels: Iterator[model.Submodel] = self._get_all_obj_of_type(model.Submodel)
482484
id_short = request.args.get("idShort")
483485
if id_short is not None:
@@ -488,19 +490,19 @@ def _get_submodels(self, request: Request) -> Tuple[Iterator[model.Submodel], Op
488490
semantic_id, model.Reference, False # type: ignore[type-abstract]
489491
)
490492
submodels = filter(lambda sm: sm.semantic_id == spec_semantic_id, submodels)
491-
paginated_submodels, end_index = self._get_slice(request, submodels)
492-
return paginated_submodels, end_index
493+
paginated_submodels, paging_metadata = self._get_slice(request, submodels)
494+
return paginated_submodels, paging_metadata
493495

494496
def _get_submodel(self, url_args: Dict) -> model.Submodel:
495497
return self._get_obj_ts(url_args["submodel_id"], model.Submodel)
496498

497499
def _get_submodel_submodel_elements(
498500
self, request: Request, url_args: Dict
499-
) -> Tuple[Iterator[model.SubmodelElement], Optional[int]]:
501+
) -> Tuple[Iterator[model.SubmodelElement], Optional[PagingMetadata]]:
500502
submodel = self._get_submodel(url_args)
501503
paginated_submodel_elements: Iterator[model.SubmodelElement]
502-
paginated_submodel_elements, end_index = self._get_slice(request, submodel.submodel_element)
503-
return paginated_submodel_elements, end_index
504+
paginated_submodel_elements, paging_metadata = self._get_slice(request, submodel.submodel_element)
505+
return paginated_submodel_elements, paging_metadata
504506

505507
def _get_submodel_submodel_elements_id_short_path(self, url_args: Dict) -> model.SubmodelElement:
506508
submodel = self._get_submodel(url_args)
@@ -523,8 +525,8 @@ def get_description(self, request: Request, url_args: Dict, response_t: Type[API
523525

524526
# ------ AAS REPO ROUTES -------
525527
def get_aas_all(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response:
526-
aashells, cursor = self._get_shells(request)
527-
return response_t(list(aashells), cursor=cursor)
528+
aashells, paging_metadata = self._get_shells(request)
529+
return response_t(list(aashells), paging_metadata=paging_metadata)
528530

529531
def post_aas(
530532
self, request: Request, url_args: Dict, response_t: Type[APIResponse], map_adapter: MapAdapter
@@ -540,9 +542,9 @@ def post_aas(
540542
def get_aas_all_reference(
541543
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs
542544
) -> Response:
543-
aashells, cursor = self._get_shells(request)
545+
aashells, paging_metadata = self._get_shells(request)
544546
references: list[model.ModelReference] = [model.ModelReference.from_referable(aas) for aas in aashells]
545-
return response_t(references, cursor=cursor)
547+
return response_t(references, paging_metadata=paging_metadata)
546548

547549
# --------- AAS ROUTES ---------
548550
def get_aas(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response:
@@ -651,8 +653,8 @@ def aas_submodel_refs_redirect(
651653

652654
# ------ SUBMODEL REPO ROUTES -------
653655
def get_submodel_all(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response:
654-
submodels, cursor = self._get_submodels(request)
655-
return response_t(list(submodels), cursor=cursor, stripped=is_stripped_request(request))
656+
submodels, paging_metadata = self._get_submodels(request)
657+
return response_t(list(submodels), paging_metadata=paging_metadata, stripped=is_stripped_request(request))
656658

657659
def post_submodel(
658660
self, request: Request, url_args: Dict, response_t: Type[APIResponse], map_adapter: MapAdapter
@@ -669,17 +671,17 @@ def get_submodel_all_metadata(self, request: Request, url_args: Dict, response_t
669671
**_kwargs) -> Response:
670672
if "level" in request.args:
671673
raise BadRequest(f"level cannot be used when retrieving metadata!")
672-
submodels, cursor = self._get_submodels(request)
673-
return response_t(list(submodels), cursor=cursor, stripped=True)
674+
submodels, paging_metadata = self._get_submodels(request)
675+
return response_t(list(submodels), paging_metadata=paging_metadata, stripped=True)
674676

675677
def get_submodel_all_reference(
676678
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs
677679
) -> Response:
678-
submodels, cursor = self._get_submodels(request)
680+
submodels, paging_metadata = self._get_submodels(request)
679681
references: list[model.ModelReference] = [
680682
model.ModelReference.from_referable(submodel) for submodel in submodels
681683
]
682-
return response_t(references, cursor=cursor, stripped=is_stripped_request(request))
684+
return response_t(references, paging_metadata=paging_metadata, stripped=is_stripped_request(request))
683685

684686
# --------- SUBMODEL ROUTES ---------
685687

@@ -713,24 +715,26 @@ def put_submodel(self, request: Request, url_args: Dict, response_t: Type[APIRes
713715
def get_submodel_submodel_elements(
714716
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs
715717
) -> Response:
716-
submodel_elements, cursor = self._get_submodel_submodel_elements(request, url_args)
717-
return response_t(list(submodel_elements), cursor=cursor, stripped=is_stripped_request(request))
718+
submodel_elements, paging_metadata = self._get_submodel_submodel_elements(request, url_args)
719+
return response_t(
720+
list(submodel_elements), paging_metadata=paging_metadata, stripped=is_stripped_request(request)
721+
)
718722

719723
def get_submodel_submodel_elements_metadata(self, request: Request, url_args: Dict, response_t: Type[APIResponse],
720724
**_kwargs) -> Response:
721725
if "level" in request.args:
722726
raise BadRequest(f"level cannot be used when retrieving metadata!")
723-
submodel_elements, cursor = self._get_submodel_submodel_elements(request, url_args)
724-
return response_t(list(submodel_elements), cursor=cursor, stripped=True)
727+
submodel_elements, paging_metadata = self._get_submodel_submodel_elements(request, url_args)
728+
return response_t(list(submodel_elements), paging_metadata=paging_metadata, stripped=True)
725729

726730
def get_submodel_submodel_elements_reference(
727731
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs
728732
) -> Response:
729-
submodel_elements, cursor = self._get_submodel_submodel_elements(request, url_args)
733+
submodel_elements, paging_metadata = self._get_submodel_submodel_elements(request, url_args)
730734
references: list[model.ModelReference] = [
731735
model.ModelReference.from_referable(element) for element in list(submodel_elements)
732736
]
733-
return response_t(references, cursor=cursor, stripped=is_stripped_request(request))
737+
return response_t(references, paging_metadata=paging_metadata, stripped=is_stripped_request(request))
734738

735739
def get_submodel_submodel_elements_id_short_path(
736740
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs

0 commit comments

Comments
 (0)