Skip to content

Commit 21ab63a

Browse files
hpoeches-heppner
andauthored
server: Correct pagination cursor calculation and limit default (#526)
* server: fix cursor value returned by _get_slice() The next_cursor value returned by the _get_slice() method was not converted from 0-based indexing back to 1-based indexing. To fix this, a value of 1 is added to a returned cursor value. Fixes #520 * server base: fix default value of pagination limit The default value for the pagination limit in the method `_get_slice()` was set to 10. The [spec] specifies a different value of 100. This changes correct the implementation to be coherent with the spec. Fixes #521 [spec]: https://industrialdigitaltwin.io/aas-specifications/IDTA-01002/v3.1.2/http-rest-api/http-rest-api.html#pagination * 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 --------- Co-authored-by: s-heppner <iat@s-heppner.com>
1 parent f2ef2c1 commit 21ab63a

3 files changed

Lines changed: 77 additions & 49 deletions

File tree

server/app/interfaces/base.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,31 +82,37 @@ def __init__(self, success: bool, messages: Optional[List[Message]] = None):
8282
ResponseData = Union[Result, object, List[object]]
8383

8484

85+
class PagingMetadata:
86+
def __init__(self, cursor: Optional[str] = None):
87+
self.cursor = cursor
88+
89+
8590
class APIResponse(abc.ABC, Response):
8691
@abc.abstractmethod
8792
def __init__(
88-
self, obj: Optional[ResponseData] = None, cursor: Optional[int] = None, stripped: bool = False, *args, **kwargs
93+
self, obj: Optional[ResponseData] = None, paging_metadata: Optional[PagingMetadata] = None,
94+
stripped: bool = False, *args, **kwargs
8995
):
9096
super().__init__(*args, **kwargs)
9197
if obj is None:
9298
self.status_code = 204
9399
else:
94-
self.data = self.serialize(obj, cursor, stripped)
100+
self.data = self.serialize(obj, paging_metadata, stripped)
95101

96102
@abc.abstractmethod
97-
def serialize(self, obj: ResponseData, cursor: Optional[int], stripped: bool) -> str:
103+
def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata], stripped: bool) -> str:
98104
pass
99105

100106

101107
class JsonResponse(APIResponse):
102108
def __init__(self, *args, content_type="application/json", **kwargs):
103109
super().__init__(*args, **kwargs, content_type=content_type)
104110

105-
def serialize(self, obj: ResponseData, cursor: Optional[int], stripped: bool) -> str:
106-
if cursor is None:
111+
def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata], stripped: bool) -> str:
112+
if paging_metadata is None:
107113
data = obj
108114
else:
109-
data = {"paging_metadata": {"cursor": str(cursor)}, "result": obj}
115+
data = {"paging_metadata": paging_metadata, "result": obj}
110116
return json.dumps(
111117
data, cls=StrippedResultToJsonEncoder if stripped else ResultToJsonEncoder, separators=(",", ":")
112118
)
@@ -116,10 +122,10 @@ class XmlResponse(APIResponse):
116122
def __init__(self, *args, content_type="application/xml", **kwargs):
117123
super().__init__(*args, **kwargs, content_type=content_type)
118124

119-
def serialize(self, obj: ResponseData, cursor: Optional[int], stripped: bool) -> str:
125+
def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata], stripped: bool) -> str:
120126
root_elem = etree.Element("response", nsmap=XML_NS_MAP)
121-
if cursor is not None or not (isinstance(obj, list) and not obj):
122-
root_elem.set("cursor", str(cursor))
127+
if paging_metadata is not None:
128+
root_elem.set("cursor", str(paging_metadata.cursor))
123129
if isinstance(obj, Result):
124130
result_elem = self.result_to_xml(obj, **XML_NS_MAP)
125131
for child in result_elem:
@@ -187,13 +193,22 @@ def _message_to_json(cls, message: Message) -> Dict[str, object]:
187193
"timestamp": message.timestamp.isoformat(),
188194
}
189195

196+
@classmethod
197+
def _paging_metadata_to_json(cls, metadata: PagingMetadata) -> Dict[str, object]:
198+
json_result: Dict[str, object] = dict()
199+
if metadata.cursor is not None:
200+
json_result["cursor"] = str(metadata.cursor)
201+
return json_result
202+
190203
def default(self, obj: object) -> object:
191204
if isinstance(obj, Result):
192205
return self._result_to_json(obj)
193206
if isinstance(obj, Message):
194207
return self._message_to_json(obj)
195208
if isinstance(obj, MessageType):
196209
return str(obj)
210+
if isinstance(obj, PagingMetadata):
211+
return self._paging_metadata_to_json(obj)
197212
return super().default(obj)
198213

199214

@@ -210,8 +225,8 @@ def __call__(self, environ, start_response) -> Iterable[bytes]:
210225
return response(environ, start_response)
211226

212227
@classmethod
213-
def _get_slice(cls, request: Request, iterator: Iterable[T]) -> Tuple[Iterator[T], Optional[int]]:
214-
limit_str = request.args.get("limit", default="10")
228+
def _get_slice(cls, request: Request, iterator: Iterable[T]) -> Tuple[Iterator[T], Optional[PagingMetadata]]:
229+
limit_str = request.args.get("limit", default="100")
215230
cursor_str = request.args.get("cursor", default="1")
216231
try:
217232
limit, cursor = (NonNegativeInteger(int(limit_str)),
@@ -223,8 +238,14 @@ def _get_slice(cls, request: Request, iterator: Iterable[T]) -> Tuple[Iterator[T
223238
items = list(itertools.islice(iterator, start_index, end_index + 1))
224239
has_more = len(items) > limit
225240
paginated_slice = iter(items[:limit])
226-
next_cursor = cursor + limit if has_more else None
227-
return paginated_slice, next_cursor
241+
next_cursor = str(cursor + limit + 1) if has_more else None
242+
243+
if next_cursor is not None or cursor > 0:
244+
# add metadata if cursor was present in request
245+
paging_metadata = PagingMetadata(cursor=next_cursor)
246+
else:
247+
paging_metadata = None
248+
return paginated_slice, paging_metadata
228249

229250
def handle_request(self, request: Request):
230251
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, ServiceSpecificationProfileEnum, ServiceDescription
2121
from app.util.converters import IdentifierToBase64URLConverter, base64url_decode
2222

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

116116
def _get_all_aas_descriptors(
117117
self, request: "Request"
118-
) -> Tuple[Iterator[server_model.AssetAdministrationShellDescriptor], Optional[int]]:
118+
) -> Tuple[Iterator[server_model.AssetAdministrationShellDescriptor], Optional[PagingMetadata]]:
119119

120120
descriptors: Iterator[server_model.AssetAdministrationShellDescriptor] = self._get_all_obj_of_type(
121121
server_model.AssetAdministrationShellDescriptor
@@ -141,20 +141,20 @@ def _get_all_aas_descriptors(
141141
raise BadRequest(f"Invalid assetType: '{asset_type}'")
142142
descriptors = filter(lambda desc: desc.asset_type == asset_type, descriptors)
143143

144-
paginated_descriptors, end_index = self._get_slice(request, descriptors)
145-
return paginated_descriptors, end_index
144+
paginated_descriptors, paging_metadata = self._get_slice(request, descriptors)
145+
return paginated_descriptors, paging_metadata
146146

147147
def _get_aas_descriptor(self, url_args: Dict) -> server_model.AssetAdministrationShellDescriptor:
148148
return self._get_obj_ts(url_args["aas_id"], server_model.AssetAdministrationShellDescriptor)
149149

150150
def _get_all_submodel_descriptors(self, request: Request) -> Tuple[
151-
Iterator[server_model.SubmodelDescriptor], Optional[int]
151+
Iterator[server_model.SubmodelDescriptor], Optional[PagingMetadata]
152152
]:
153153
submodel_descriptors: Iterator[server_model.SubmodelDescriptor] = self._get_all_obj_of_type(
154154
server_model.SubmodelDescriptor
155155
)
156-
paginated_submodel_descriptors, end_index = self._get_slice(request, submodel_descriptors)
157-
return paginated_submodel_descriptors, end_index
156+
paginated_submodel_descriptors, paging_metadata = self._get_slice(request, submodel_descriptors)
157+
return paginated_submodel_descriptors, paging_metadata
158158

159159
def _get_submodel_descriptor(self, url_args: Dict) -> server_model.SubmodelDescriptor:
160160
return self._get_obj_ts(url_args["submodel_id"], server_model.SubmodelDescriptor)
@@ -169,8 +169,8 @@ def get_self_description(
169169
def get_all_aas_descriptors(
170170
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs
171171
) -> Response:
172-
aas_descriptors, cursor = self._get_all_aas_descriptors(request)
173-
return response_t(list(aas_descriptors), cursor=cursor)
172+
aas_descriptors, paging_metadata = self._get_all_aas_descriptors(request)
173+
return response_t(list(aas_descriptors), paging_metadata=paging_metadata)
174174

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

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

server/app/interfaces/repository.py

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +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 PagingMetadata
2526
from app.util.converters import IdentifierToBase64URLConverter, IdShortPathConverter, base64url_decode
2627
from .base import ObjectStoreWSGIApp, APIResponse, is_stripped_request, HTTPApiDecoder, T
2728
from app.model import ServiceSpecificationProfileEnum, ServiceDescription
@@ -429,7 +430,9 @@ def _get_submodel_reference(
429430
return ref
430431
raise NotFound(f"The AAS {aas!r} doesn't have a submodel reference to {submodel_id!r}!")
431432

432-
def _get_shells(self, request: Request) -> Tuple[Iterator[model.AssetAdministrationShell], Optional[int]]:
433+
def _get_shells(
434+
self, request: Request
435+
) -> Tuple[Iterator[model.AssetAdministrationShell], Optional[PagingMetadata]]:
433436
aas: Iterator[model.AssetAdministrationShell] = self._get_all_obj_of_type(model.AssetAdministrationShell)
434437

435438
id_short = request.args.get("idShort")
@@ -475,13 +478,13 @@ def _get_shells(self, request: Request) -> Tuple[Iterator[model.AssetAdministrat
475478
aas,
476479
)
477480

478-
paginated_aas, end_index = self._get_slice(request, aas)
479-
return paginated_aas, end_index
481+
paginated_aas, paging_metadata = self._get_slice(request, aas)
482+
return paginated_aas, paging_metadata
480483

481484
def _get_shell(self, url_args: Dict) -> model.AssetAdministrationShell:
482485
return self._get_obj_ts(url_args["aas_id"], model.AssetAdministrationShell)
483486

484-
def _get_submodels(self, request: Request) -> Tuple[Iterator[model.Submodel], Optional[int]]:
487+
def _get_submodels(self, request: Request) -> Tuple[Iterator[model.Submodel], Optional[PagingMetadata]]:
485488
submodels: Iterator[model.Submodel] = self._get_all_obj_of_type(model.Submodel)
486489
id_short = request.args.get("idShort")
487490
if id_short is not None:
@@ -492,19 +495,19 @@ def _get_submodels(self, request: Request) -> Tuple[Iterator[model.Submodel], Op
492495
semantic_id, model.Reference, False # type: ignore[type-abstract]
493496
)
494497
submodels = filter(lambda sm: sm.semantic_id == spec_semantic_id, submodels)
495-
paginated_submodels, end_index = self._get_slice(request, submodels)
496-
return paginated_submodels, end_index
498+
paginated_submodels, paging_metadata = self._get_slice(request, submodels)
499+
return paginated_submodels, paging_metadata
497500

498501
def _get_submodel(self, url_args: Dict) -> model.Submodel:
499502
return self._get_obj_ts(url_args["submodel_id"], model.Submodel)
500503

501504
def _get_submodel_submodel_elements(
502505
self, request: Request, url_args: Dict
503-
) -> Tuple[Iterator[model.SubmodelElement], Optional[int]]:
506+
) -> Tuple[Iterator[model.SubmodelElement], Optional[PagingMetadata]]:
504507
submodel = self._get_submodel(url_args)
505508
paginated_submodel_elements: Iterator[model.SubmodelElement]
506-
paginated_submodel_elements, end_index = self._get_slice(request, submodel.submodel_element)
507-
return paginated_submodel_elements, end_index
509+
paginated_submodel_elements, paging_metadata = self._get_slice(request, submodel.submodel_element)
510+
return paginated_submodel_elements, paging_metadata
508511

509512
def _get_submodel_submodel_elements_id_short_path(self, url_args: Dict) -> model.SubmodelElement:
510513
submodel = self._get_submodel(url_args)
@@ -523,8 +526,8 @@ def get_description(self, request: Request, url_args: Dict, response_t: Type[API
523526

524527
# ------ AAS REPO ROUTES -------
525528
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)
529+
aashells, paging_metadata = self._get_shells(request)
530+
return response_t(list(aashells), paging_metadata=paging_metadata)
528531

529532
def post_aas(
530533
self, request: Request, url_args: Dict, response_t: Type[APIResponse], map_adapter: MapAdapter
@@ -540,9 +543,9 @@ def post_aas(
540543
def get_aas_all_reference(
541544
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs
542545
) -> Response:
543-
aashells, cursor = self._get_shells(request)
546+
aashells, paging_metadata = self._get_shells(request)
544547
references: list[model.ModelReference] = [model.ModelReference.from_referable(aas) for aas in aashells]
545-
return response_t(references, cursor=cursor)
548+
return response_t(references, paging_metadata=paging_metadata)
546549

547550
# --------- AAS ROUTES ---------
548551
def get_aas(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response:
@@ -651,8 +654,8 @@ def aas_submodel_refs_redirect(
651654

652655
# ------ SUBMODEL REPO ROUTES -------
653656
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))
657+
submodels, paging_metadata = self._get_submodels(request)
658+
return response_t(list(submodels), paging_metadata=paging_metadata, stripped=is_stripped_request(request))
656659

657660
def post_submodel(
658661
self, request: Request, url_args: Dict, response_t: Type[APIResponse], map_adapter: MapAdapter
@@ -669,17 +672,17 @@ def get_submodel_all_metadata(self, request: Request, url_args: Dict, response_t
669672
**_kwargs) -> Response:
670673
if "level" in request.args:
671674
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)
675+
submodels, paging_metadata = self._get_submodels(request)
676+
return response_t(list(submodels), paging_metadata=paging_metadata, stripped=True)
674677

675678
def get_submodel_all_reference(
676679
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs
677680
) -> Response:
678-
submodels, cursor = self._get_submodels(request)
681+
submodels, paging_metadata = self._get_submodels(request)
679682
references: list[model.ModelReference] = [
680683
model.ModelReference.from_referable(submodel) for submodel in submodels
681684
]
682-
return response_t(references, cursor=cursor, stripped=is_stripped_request(request))
685+
return response_t(references, paging_metadata=paging_metadata, stripped=is_stripped_request(request))
683686

684687
# --------- SUBMODEL ROUTES ---------
685688

@@ -713,24 +716,26 @@ def put_submodel(self, request: Request, url_args: Dict, response_t: Type[APIRes
713716
def get_submodel_submodel_elements(
714717
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs
715718
) -> 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))
719+
submodel_elements, paging_metadata = self._get_submodel_submodel_elements(request, url_args)
720+
return response_t(
721+
list(submodel_elements), paging_metadata=paging_metadata, stripped=is_stripped_request(request)
722+
)
718723

719724
def get_submodel_submodel_elements_metadata(self, request: Request, url_args: Dict, response_t: Type[APIResponse],
720725
**_kwargs) -> Response:
721726
if "level" in request.args:
722727
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)
728+
submodel_elements, paging_metadata = self._get_submodel_submodel_elements(request, url_args)
729+
return response_t(list(submodel_elements), paging_metadata=paging_metadata, stripped=True)
725730

726731
def get_submodel_submodel_elements_reference(
727732
self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs
728733
) -> Response:
729-
submodel_elements, cursor = self._get_submodel_submodel_elements(request, url_args)
734+
submodel_elements, paging_metadata = self._get_submodel_submodel_elements(request, url_args)
730735
references: list[model.ModelReference] = [
731736
model.ModelReference.from_referable(element) for element in list(submodel_elements)
732737
]
733-
return response_t(references, cursor=cursor, stripped=is_stripped_request(request))
738+
return response_t(references, paging_metadata=paging_metadata, stripped=is_stripped_request(request))
734739

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

0 commit comments

Comments
 (0)