diff --git a/api/server/routes/aas.py b/api/server/routes/aas.py index 445b6a0..ac83e91 100644 --- a/api/server/routes/aas.py +++ b/api/server/routes/aas.py @@ -1,14 +1,14 @@ from typing import Any from aas_core3.types import Identifiable -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, HTTPException from server.services.aas_service import AasService -from server.utils.pagination import Pagination from basyx import ObjectStore +from server.utils.decorator import paginated -class AasRouter(Pagination): +class AasRouter: def __init__(self, global_obj_store: ObjectStore[Identifiable]): self.router = APIRouter() self.service = AasService(global_obj_store) @@ -16,7 +16,8 @@ def __init__(self, global_obj_store: ObjectStore[Identifiable]): def _setup_routes(self): @self.router.get("/shells") - async def get_all_aas() -> Any: + @paginated() + async def get_all_aas(request: Request) -> Any: return self.service.get_all_shells_as_jsonable() @self.router.post("/shells") @@ -26,7 +27,7 @@ async def create_aas(request: Request) -> Any: @self.router.get("/shells/$reference") async def get_all_aas_reference() -> Any: - return {"message": "Content parameters are not supported yet."} + raise HTTPException(status_code=501, detail="This route is not yet implemented!") @self.router.get("/shells/{aas_identifier}") async def get_aas_by_id(aas_identifier: str) -> Any: diff --git a/api/server/routes/aas_registry_server.py b/api/server/routes/aas_registry_server.py index 4b2dfa2..08bf120 100644 --- a/api/server/routes/aas_registry_server.py +++ b/api/server/routes/aas_registry_server.py @@ -6,6 +6,8 @@ from server.services.aas_registry_server_service import AasRegistryServerService from basyx import ObjectStore +from server.utils.decorator import paginated + class AasRegistryRouter: def __init__(self, global_obj_store: ObjectStore[Identifiable]): @@ -16,7 +18,8 @@ def __init__(self, global_obj_store: ObjectStore[Identifiable]): def _setup_routes(self): @self.router.get("/") - async def get_all_aas_descriptors() -> Any: + @paginated() + async def get_all_aas_descriptors(request: Request) -> Any: return self.service.get_all_asset_administration_shell_descriptors() @self.router.get("/{aas_descriptor_id}") diff --git a/api/server/routes/aasx_file_server.py b/api/server/routes/aasx_file_server.py index cdc7bcc..e10872f 100644 --- a/api/server/routes/aasx_file_server.py +++ b/api/server/routes/aasx_file_server.py @@ -6,6 +6,8 @@ from server.services.aasx_file_server_service import AasxFileServerService from basyx import ObjectStore +from server.utils.decorator import paginated + class AasxFileServerRouter: def __init__(self, global_obj_store: ObjectStore[Identifiable]): @@ -16,7 +18,8 @@ def __init__(self, global_obj_store: ObjectStore[Identifiable]): def _setup_routes(self): @self.router.get("") - async def get_all_aasx() -> Any: + @paginated() + async def get_all_aasx(request: Request) -> Any: return self.service.get_all_aasx_package_ids() @self.router.get("/{aasx_package_id}") diff --git a/api/server/routes/submodel.py b/api/server/routes/submodel.py index 4be004d..2cb280d 100644 --- a/api/server/routes/submodel.py +++ b/api/server/routes/submodel.py @@ -6,11 +6,10 @@ from server.services.submodel_service import SubmodelService from basyx import ObjectStore -from server.utils.pagination import Pagination -from server.utils.decorator import limited +from server.utils.decorator import paginated -class SubmodelRouter(Pagination): +class SubmodelRouter: def __init__(self, global_obj_store: ObjectStore[Identifiable]): self.router = APIRouter() self.obj_store = global_obj_store @@ -20,24 +19,24 @@ def __init__(self, global_obj_store: ObjectStore[Identifiable]): def _setup_routes(self): # GetAllSubmodels and path-suffixes @self.router.get("") - @limited() + @paginated() async def get_submodel_all(request: Request) -> Any: return self.service.get_all_submodels_as_jsonables() @self.router.get("/$metadata") - @limited() + @paginated() async def get_submodel_all_metadata(request: Request) -> Any: # Returns metadata for all submodels, stripped of detailed content raise HTTPException(status_code=501, detail="This route is not yet implemented!") @self.router.get("/$reference") - @limited() + @paginated() async def get_submodel_all_reference(request: Request) -> Any: # Returns references for all submodels without full data raise HTTPException(status_code=501, detail="This route is not yet implemented!") @self.router.get("/$value") - @limited() + @paginated() async def not_implemented_value(request: Request) -> Any: raise HTTPException(status_code=501, detail="This route is not implemented!") @@ -99,7 +98,8 @@ async def post_submodel_elements(submodel_identifier: str, request: Request) -> return self.service.post_submodel_element(submodel_identifier, body) @self.router.get("/{submodel_identifier}/submodel-elements") - async def get_submodel_submodel_elements(submodel_identifier: str) -> Any: + @paginated() + async def get_submodel_submodel_elements(request: Request, submodel_identifier: str) -> Any: # Get submodel elements self.service.get_submodel_elements(submodel_identifier) @@ -121,7 +121,8 @@ async def not_implemented_submodel_elements_path(submodel_identifier: str) -> An raise HTTPException(status_code=501, detail="This route is not yet implemented!") @self.router.get("/{submodel_identifier}/submodel-elements/{id_short_path}") - async def get_submodel_submodel_elements_id_short_path(submodel_identifier: str, id_short_path: str) -> Any: + @paginated() + async def get_submodel_submodel_elements_id_short_path(request: Request, submodel_identifier: str, id_short_path: str) -> Any: return self.service.get_submodel_element(submodel_identifier, id_short_path) @self.router.post("/{submodel_identifier}/submodel-elements/{id_short_path}") diff --git a/api/server/routes/submodel_registry_server.py b/api/server/routes/submodel_registry_server.py index e5f976a..b01a672 100644 --- a/api/server/routes/submodel_registry_server.py +++ b/api/server/routes/submodel_registry_server.py @@ -6,6 +6,8 @@ from server.services.submodel_registry_server_service import SubmodelRegistryServerService from basyx import ObjectStore +from server.utils.decorator import paginated + class SubmodelRegistryRouter: def __init__(self, global_obj_store: ObjectStore[Identifiable]): @@ -16,7 +18,8 @@ def __init__(self, global_obj_store: ObjectStore[Identifiable]): def _setup_routes(self): @self.router.get("/") - async def get_all_submodel_descriptors() -> Any: + @paginated() + async def get_all_submodel_descriptors(request: Request) -> Any: return self.service.get_all_submodel_descriptors() @self.router.get("/{submodel_id}") diff --git a/api/server/utils/decorator.py b/api/server/utils/decorator.py index 8035b4a..c3dc7f2 100644 --- a/api/server/utils/decorator.py +++ b/api/server/utils/decorator.py @@ -1,28 +1,36 @@ from functools import wraps from fastapi import Request -from typing import Callable, Any, Union +from typing import Callable, Any, Union, Tuple -def limited(default_limit: int = 100): +def paginated(default_limit: int = 100): def decorator(func: Callable): @wraps(func) async def wrapper(*args, **kwargs) -> Any: - # Extract request from kwargs (FastAPI automatically passes it) request: Request = kwargs.get("request") - limit: Union[str, int] = request.query_params.get("limit", default_limit) + query = request.query_params try: - limit = int(limit) # Ensure limit is an integer + limit = int(query.get("limit", default_limit)) + cursor = int(query.get("cursor", 0)) + if limit < 0 or cursor < 0: + raise ValueError except ValueError: - limit = default_limit # Fallback if conversion fails + from fastapi import HTTPException + raise HTTPException(status_code=400, detail="Cursor and limit must be >0") - # Call the original function result = await func(*args, **kwargs) - # Apply limit if the result is a list if isinstance(result, list): - return result[:limit] - return result # If not a list, return as-is + # FIXME: Is this correct? Following Spec p.119 + return { + "result": result[cursor:cursor + limit], + "paging_metadata": { + "next_cursor": cursor + limit if cursor + limit < len(result) else None + } + } + + return result return wrapper diff --git a/api/server/utils/pagination.py b/api/server/utils/pagination.py deleted file mode 100644 index f04fff6..0000000 --- a/api/server/utils/pagination.py +++ /dev/null @@ -1,7 +0,0 @@ -from typing import Iterable, Any - - -class Pagination: - # TODO: Add cursor - def paginate(self, collection: Iterable[Any], limit: int) -> Iterable[Any]: - return list(collection)[:limit] diff --git a/api/test/__init__.py b/api/test/__init__.py index e69de29..40bf90d 100644 --- a/api/test/__init__.py +++ b/api/test/__init__.py @@ -0,0 +1,7 @@ +def wrap_paginated(result): + return { + "result": result, + "paging_metadata": { + "next_cursor": None + } + } diff --git a/api/test/examples/empty_paged_result.json b/api/test/examples/empty_paged_result.json new file mode 100644 index 0000000..66f016f --- /dev/null +++ b/api/test/examples/empty_paged_result.json @@ -0,0 +1,6 @@ +{ + "result": [], + "paging_metadata": { + "next_cursor": null + } +} diff --git a/api/test/examples/submodel/submodel2.json b/api/test/examples/submodel/submodel2.json new file mode 100644 index 0000000..4bb0825 --- /dev/null +++ b/api/test/examples/submodel/submodel2.json @@ -0,0 +1,37 @@ +{ + "id": "urn:x-test:submodel2", + "submodelElements": [ + { + "idShort": "some_property", + "valueType": "xs:int", + "value": "1984", + "modelType": "Property" + }, + { + "idShort": "some_blob", + "value": "3q2+7w==", + "contentType": "application/octet-stream", + "modelType": "Blob" + }, + { + "idShort": "ExampleSubmodelList", + "typeValueListElement": "SubmodelElementList", + "value": [ + { + "idShort": "list_1", + "value": "3q2+7w==", + "contentType": "application/octet-stream", + "modelType": "Blob" + }, + { + "idShort": "list_2", + "value": "3q2+7w==", + "contentType": "application/octet-stream", + "modelType": "Blob" + } + ], + "modelType": "SubmodelElementList" + } + ], + "modelType": "Submodel" +} diff --git a/api/test/test_aas_service.py b/api/test/test_aas_service.py index 96028b0..54f17f9 100644 --- a/api/test/test_aas_service.py +++ b/api/test/test_aas_service.py @@ -5,6 +5,8 @@ from server import app +from test import wrap_paginated + BASE_URL = "/api/v3.0/" @@ -31,6 +33,9 @@ def setUp(self): with open(os.path.join(base_path, "examples/aas", "thumbnail_modified.json"), encoding="utf-8") as f: self.thumbnail_example_modified = json.load(f) + with open(os.path.join(base_path, "examples", "empty_paged_result.json"), encoding="utf-8") as f: + self.empty_result = json.load(f) + # FIXME: modified AAS should contain more complex types but deserialization seems to fail with open(os.path.join(base_path, "examples/aas", "aas_modified.json"), encoding="utf-8") as f: self.aas_example_modified = json.load(f) @@ -48,14 +53,14 @@ def test_aas_post(self): def test_get_all_shells(self): response = self.client.get(BASE_URL + "aas/shells") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), []) + self.assertEqual(response.json(), self.empty_result) # Setup self.client.post(BASE_URL + "aas/shells", json=self.aas_example) response = self.client.get(BASE_URL + "aas/shells") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), [self.aas_example]) + self.assertEqual(response.json(), wrap_paginated([self.aas_example])) # Teardown self.client.delete(BASE_URL + "ass/shells/" + self.shell_example_id) diff --git a/api/test/test_aasx_file_server_service.py b/api/test/test_aasx_file_server_service.py index 260837e..35ef143 100644 --- a/api/test/test_aasx_file_server_service.py +++ b/api/test/test_aasx_file_server_service.py @@ -6,6 +6,8 @@ from server import app from aas_core3 import jsonization +from test import wrap_paginated + client = TestClient(app) BASE_URL = "/api/v3.0/" @@ -18,6 +20,10 @@ def setUp(self): with open(os.path.join(base_path, "examples/aasx", "aasx.json"), encoding="utf-8") as f: self.aasx_json = json.load(f) + with open(os.path.join(base_path, "examples", "empty_paged_result.json"), encoding="utf-8") as f: + self.empty_result = json.load(f) + + self.test_aasx_id = self.aasx_json["id"] self.aasx = jsonization.asset_administration_shell_from_jsonable(self.aasx_json) @@ -25,14 +31,14 @@ def test_get_all_aasx_package_ids(self): # Test empty response = self.client.get(BASE_URL + "aasx") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), []) + self.assertEqual(response.json(), self.empty_result) # Setup self.client.post(BASE_URL + "aasx", json=self.aasx_json) response = self.client.get(BASE_URL + "aasx") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), [self.test_aasx_id]) + self.assertEqual(response.json(), wrap_paginated([self.test_aasx_id])) # Teardown self.client.delete(BASE_URL + "aasx/" + self.test_aasx_id) diff --git a/api/test/test_submodel_service.py b/api/test/test_submodel_service.py index 2a86c7e..3d91c68 100644 --- a/api/test/test_submodel_service.py +++ b/api/test/test_submodel_service.py @@ -6,6 +6,8 @@ from server import app +from test import wrap_paginated + client = TestClient(app) BASE_URL = "/api/v3.0/" @@ -17,6 +19,8 @@ def setUp(self): with open(os.path.join(base_path, "examples/submodel", "submodel.json"), encoding="utf-8") as f: self.submodel_example = json.load(f) + with open(os.path.join(base_path, "examples/submodel", "submodel2.json"), encoding="utf-8") as f: + self.submodel_example_2 = json.load(f) with open(os.path.join(base_path, "examples/submodel", "submodel_modified.json"), encoding="utf-8") as f: self.test_submodel_modified = json.load(f) with open(os.path.join(base_path, "examples/submodel", "submodel_element.json"), encoding="utf-8") as f: @@ -25,8 +29,12 @@ def setUp(self): self.submodel_element_new = json.load(f) with open(os.path.join(base_path, "examples/submodel", "submodel_with_new_element.json"), encoding="utf-8") as f: self.submodel_with_new_element = json.load(f) + with open(os.path.join(base_path, "examples", "empty_paged_result.json"), encoding="utf-8") as f: + self.empty_result = json.load(f) + self.submodel_example_id = self.submodel_example["id"] + self.submodel_example_2_id = self.submodel_example_2["id"] self.invalid_submodel_id = "some_id" self.invalid_submodel_element_id = "some_unknown_element_id" @@ -34,14 +42,14 @@ def setUp(self): def test_get_all_submodels(self): response = self.client.get(BASE_URL + "submodels") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), []) + self.assertEqual(response.json(), self.empty_result) # Setup self.client.post(BASE_URL + "submodels", json=self.submodel_example) response = self.client.get(BASE_URL + "submodels") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), [self.submodel_example]) + self.assertEqual(response.json(), wrap_paginated([self.submodel_example])) # Teardown self.client.delete(BASE_URL + "submodels/" + self.submodel_example_id) @@ -98,6 +106,9 @@ def test_post_submodel_element(self): self.assertEqual(new_submodel.status_code, 200) self.assertEqual(new_submodel.json(), self.submodel_with_new_element) + # Teardown + self.client.delete(BASE_URL + "submodels/" + self.submodel_example_id + "/") + def test_delete_submodel_element(self): # Setup self.client.post(BASE_URL + "submodels", json=self.submodel_example) @@ -120,7 +131,27 @@ def test_delete_submodel_element(self): # Teardown self.client.delete(BASE_URL + "submodels/" + self.submodel_example_id + "/") + def test_submodel_pagination(self): + # Setup + self.client.post(BASE_URL + "submodels", json=self.submodel_example) + self.client.post(BASE_URL + "submodels", json=self.submodel_example_2) + first_response = self.client.get(BASE_URL + "submodels?limit=1") + self.assertEqual(self.submodel_example, first_response.json()["result"][0]) + + # Extract next cursor out of paging_metadata + next_cursor = first_response.json()["paging_metadata"]["next_cursor"] + self.assertEqual(1, next_cursor) + + second_response = self.client.get(BASE_URL + "submodels?cursor=" + str(next_cursor) + "&limit=1") + self.assertEqual(self.submodel_example_2, second_response.json()["result"][0]) + + next_cursor = second_response.json()["paging_metadata"]["next_cursor"] + self.assertIsNone(next_cursor) + + # Teardown + self.client.delete(BASE_URL + "submodels/" + self.submodel_example_id + "/") + self.client.delete(BASE_URL + "submodels/" + self.submodel_example_2_id + "/") if __name__ == "__main__": unittest.main()