Skip to content

Commit 9c98a7b

Browse files
Frosty2500s-heppner
authored andcommitted
http.py: Fix redirects, bugs, and SDK installation (#362)
This fixes the redirects from the AAS repository paths to the Submodel repository paths to work properly. Furthermore, the installation of dependencies inside the server Docker image was previously using the latest release of the SDK. This means, that we could not ensure that each commit in the `main` branch of our monorepo would be interoperable between SDK and server, as a PR would have to be closed in order for the server CI would not report any errors. In order to fix this, issue in the development process, the server Docker image now installs the SDK from the local repository, rather than from GitHub. Lastly, this fixes a wrong status code reported when specifing a model that is malformed or missing information. The specification expects a 400 (Bad Request) response, but the server sent a 422 (Unprocessable Content). Fixes #315
1 parent 8bd2c54 commit 9c98a7b

File tree

5 files changed

+68
-35
lines changed

5 files changed

+68
-35
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ jobs:
320320
- uses: actions/checkout@v4
321321
- name: Build the Docker image
322322
run: |
323-
docker build -t basyx-python-server .
323+
docker build -t basyx-python-server -f Dockerfile ..
324324
- name: Run container
325325
run: |
326326
docker run -d --name basyx-python-server basyx-python-server

sdk/basyx/aas/adapter/http.py

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2024 the Eclipse BaSyx Authors
1+
# Copyright (c) 2025 the Eclipse BaSyx Authors
22
#
33
# This program and the accompanying materials are made available under the terms of the MIT License, available in
44
# the LICENSE file of this project.
@@ -42,13 +42,14 @@
4242
import io
4343
import json
4444
import itertools
45+
import urllib
4546

4647
from lxml import etree
4748
import werkzeug.exceptions
4849
import werkzeug.routing
4950
import werkzeug.urls
5051
import werkzeug.utils
51-
from werkzeug.exceptions import BadRequest, Conflict, NotFound, UnprocessableEntity
52+
from werkzeug.exceptions import BadRequest, Conflict, NotFound
5253
from werkzeug.routing import MapAdapter, Rule, Submount
5354
from werkzeug.wrappers import Request, Response
5455
from werkzeug.datastructures import FileStorage
@@ -252,7 +253,13 @@ def http_exception_to_response(exception: werkzeug.exceptions.HTTPException, res
252253

253254

254255
def is_stripped_request(request: Request) -> bool:
255-
return request.args.get("level") == "core"
256+
level = request.args.get("level")
257+
if level not in {"deep", "core", None}:
258+
raise BadRequest(f"Level {level} is not a valid level!")
259+
extent = request.args.get("extent")
260+
if extent is not None:
261+
raise werkzeug.exceptions.NotImplemented(f"The parameter extent is not yet implemented for this server!")
262+
return level == "core"
256263

257264

258265
T = TypeVar("T")
@@ -300,7 +307,7 @@ def check_type_supportance(cls, type_: type):
300307
@classmethod
301308
def assert_type(cls, obj: object, type_: Type[T]) -> T:
302309
if not isinstance(obj, type_):
303-
raise UnprocessableEntity(f"Object {obj!r} is not of type {type_.__name__}!")
310+
raise BadRequest(f"Object {obj!r} is not of type {type_.__name__}!")
304311
return obj
305312

306313
@classmethod
@@ -312,10 +319,10 @@ def json_list(cls, data: Union[str, bytes], expect_type: Type[T], stripped: bool
312319
parsed = json.loads(data, cls=decoder)
313320
if not isinstance(parsed, list):
314321
if not expect_single:
315-
raise UnprocessableEntity(f"Expected List[{expect_type.__name__}], got {parsed!r}!")
322+
raise BadRequest(f"Expected List[{expect_type.__name__}], got {parsed!r}!")
316323
parsed = [parsed]
317324
elif expect_single:
318-
raise UnprocessableEntity(f"Expected a single object of type {expect_type.__name__}, got {parsed!r}!")
325+
raise BadRequest(f"Expected a single object of type {expect_type.__name__}, got {parsed!r}!")
319326
# TODO: the following is ugly, but necessary because references aren't self-identified objects
320327
# in the json schema
321328
# TODO: json deserialization will always create an ModelReference[Submodel], xml deserialization determines
@@ -339,7 +346,7 @@ def json_list(cls, data: Union[str, bytes], expect_type: Type[T], stripped: bool
339346
return [constructor(obj, *args) for obj in parsed]
340347

341348
except (KeyError, ValueError, TypeError, json.JSONDecodeError, model.AASConstraintViolation) as e:
342-
raise UnprocessableEntity(str(e)) from e
349+
raise BadRequest(str(e)) from e
343350

344351
return [cls.assert_type(obj, expect_type) for obj in parsed]
345352

@@ -369,9 +376,9 @@ def xml(cls, data: bytes, expect_type: Type[T], stripped: bool) -> T:
369376
f: BaseException = e
370377
while f.__cause__ is not None:
371378
f = f.__cause__
372-
raise UnprocessableEntity(str(f)) from e
379+
raise BadRequest(str(f)) from e
373380
except (etree.XMLSyntaxError, model.AASConstraintViolation) as e:
374-
raise UnprocessableEntity(str(e)) from e
381+
raise BadRequest(str(e)) from e
375382
return cls.assert_type(rv, expect_type)
376383

377384
@classmethod
@@ -647,13 +654,13 @@ def _get_submodel_reference(cls, aas: model.AssetAdministrationShell, submodel_i
647654
@classmethod
648655
def _get_slice(cls, request: Request, iterator: Iterable[T]) -> Tuple[Iterator[T], int]:
649656
limit_str = request.args.get('limit', default="10")
650-
cursor_str = request.args.get('cursor', default="0")
657+
cursor_str = request.args.get('cursor', default="1")
651658
try:
652-
limit, cursor = int(limit_str), int(cursor_str)
659+
limit, cursor = int(limit_str), int(cursor_str) - 1 # cursor is 1-indexed
653660
if limit < 0 or cursor < 0:
654661
raise ValueError
655662
except ValueError:
656-
raise BadRequest("Cursor and limit must be positive integers!")
663+
raise BadRequest("Limit can not be negative, cursor must be positive!")
657664
start_index = cursor
658665
end_index = cursor + limit
659666
paginated_slice = itertools.islice(iterator, start_index, end_index)
@@ -667,14 +674,31 @@ def _get_shells(self, request: Request) -> Tuple[Iterator[model.AssetAdministrat
667674
aas = filter(lambda shell: shell.id_short == id_short, aas)
668675

669676
asset_ids = request.args.getlist("assetIds")
670-
if asset_ids is not None:
671-
# Decode and instantiate SpecificAssetIds
672-
# This needs to be a list, otherwise we can only iterate it once.
673-
specific_asset_ids: List[model.SpecificAssetId] = list(
674-
map(lambda asset_id: HTTPApiDecoder.base64urljson(asset_id, model.SpecificAssetId, False), asset_ids))
675-
# Filter AAS based on these SpecificAssetIds
676-
aas = filter(lambda shell: all(specific_asset_id in shell.asset_information.specific_asset_id
677-
for specific_asset_id in specific_asset_ids), aas)
677+
678+
if asset_ids:
679+
specific_asset_ids = []
680+
global_asset_ids = []
681+
682+
for asset_id in asset_ids:
683+
asset_id_json = base64url_decode(asset_id)
684+
asset_dict = json.loads(asset_id_json)
685+
name = asset_dict["name"]
686+
value = asset_dict["value"]
687+
688+
if name == "specificAssetId":
689+
decoded_specific_id = HTTPApiDecoder.json_list(value, model.SpecificAssetId,
690+
False, True)[0]
691+
specific_asset_ids.append(decoded_specific_id)
692+
elif name == "globalAssetId":
693+
global_asset_ids.append(value)
694+
695+
# Filter AAS based on both SpecificAssetIds and globalAssetIds
696+
aas = filter(lambda shell: (
697+
(not specific_asset_ids or all(specific_asset_id in shell.asset_information.specific_asset_id
698+
for specific_asset_id in specific_asset_ids)) and
699+
(len(global_asset_ids) <= 1 and
700+
(not global_asset_ids or shell.asset_information.global_asset_id in global_asset_ids))
701+
), aas)
678702

679703
paginated_aas, end_index = self._get_slice(request, aas)
680704
return paginated_aas, end_index
@@ -843,7 +867,7 @@ def delete_aas_submodel_refs_submodel(self, request: Request, url_args: Dict, re
843867
aas.commit()
844868
return response_t()
845869

846-
def aas_submodel_refs_redirect(self, request: Request, url_args: Dict, map_adapter: MapAdapter,
870+
def aas_submodel_refs_redirect(self, request: Request, url_args: Dict, map_adapter: MapAdapter, response_t=None,
847871
**_kwargs) -> Response:
848872
aas = self._get_shell(url_args)
849873
# the following makes sure the reference exists
@@ -852,7 +876,7 @@ def aas_submodel_refs_redirect(self, request: Request, url_args: Dict, map_adapt
852876
"submodel_id": url_args["submodel_id"]
853877
}, force_external=True)
854878
if "path" in url_args:
855-
redirect_url += url_args["path"] + "/"
879+
redirect_url += "/" + url_args["path"]
856880
if request.query_string:
857881
redirect_url += "?" + request.query_string.decode("ascii")
858882
return werkzeug.utils.redirect(redirect_url, 307)
@@ -940,6 +964,8 @@ def get_submodel_submodel_elements_id_short_path(self, request: Request, url_arg
940964
def get_submodel_submodel_elements_id_short_path_metadata(self, request: Request, url_args: Dict,
941965
response_t: Type[APIResponse], **_kwargs) -> Response:
942966
submodel_element = self._get_submodel_submodel_elements_id_short_path(url_args)
967+
if isinstance(submodel_element, model.Capability) or isinstance(submodel_element, model.Operation):
968+
raise BadRequest(f"{submodel_element.id_short} does not allow the content modifier metadata!")
943969
return response_t(submodel_element, stripped=True)
944970

945971
def get_submodel_submodel_elements_id_short_path_reference(self, request: Request, url_args: Dict,

server/Dockerfile

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,12 @@ ENV PYTHONUNBUFFERED=1
1313
RUN apk update && \
1414
apk add --no-cache nginx supervisor gcc musl-dev linux-headers python3-dev git bash && \
1515
pip install uwsgi && \
16-
pip install --no-cache-dir git+https://github.com/eclipse-basyx/basyx-python-sdk@main#subdirectory=sdk && \
1716
apk del git bash
1817

1918

20-
COPY uwsgi.ini /etc/uwsgi/
21-
COPY supervisord.ini /etc/supervisor/conf.d/supervisord.ini
22-
COPY stop-supervisor.sh /etc/supervisor/stop-supervisor.sh
19+
COPY server/uwsgi.ini /etc/uwsgi/
20+
COPY server/supervisord.ini /etc/supervisor/conf.d/supervisord.ini
21+
COPY server/stop-supervisor.sh /etc/supervisor/stop-supervisor.sh
2322
RUN chmod +x /etc/supervisor/stop-supervisor.sh
2423

2524
# Makes it possible to use a different configuration
@@ -34,12 +33,16 @@ ENV LISTEN_PORT=80
3433
ENV CLIENT_BODY_BUFFER_SIZE=1M
3534

3635
# Copy the entrypoint that will generate Nginx additional configs
37-
COPY entrypoint.sh /entrypoint.sh
36+
COPY server/entrypoint.sh /entrypoint.sh
3837
RUN chmod +x /entrypoint.sh
3938

4039
ENTRYPOINT ["/entrypoint.sh"]
4140

42-
COPY ./app /app
41+
ENV SETUPTOOLS_SCM_PRETEND_VERSION=1.0.0
42+
43+
COPY ./sdk /sdk
44+
COPY ./server/app /app
4345
WORKDIR /app
46+
RUN pip install ../sdk
4447

4548
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.ini"]

server/README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ See [below](#options) on how to configure this.
1515
## Building
1616
The container image can be built via:
1717
```
18-
$ docker buildx build -t basyx-python-sdk-http-server .
18+
$ docker build -t basyx-python-server -f Dockerfile ..
1919
```
2020

2121
## Running
@@ -46,17 +46,17 @@ The container can be configured via environment variables:
4646

4747
Putting it all together, the container can be started via the following command:
4848
```
49-
$ docker run -p 8080:80 -v ./storage:/storage basyx-python-sdk-http-server
49+
$ docker run -p 8080:80 -v ./storage:/storage basyx-python-server
5050
```
5151

5252
Since Windows uses backslashes instead of forward slashes in paths, you'll have to adjust the path to the storage directory there:
5353
```
54-
> docker run -p 8080:80 -v .\storage:/storage basyx-python-sdk-http-server
54+
> docker run -p 8080:80 -v .\storage:/storage basyx-python-server
5555
```
5656

5757
Per default, the server will use the `LOCAL_FILE_READ_ONLY` storage type and serve the API under `/api/v3.0` and read files from `/storage`. If you want to change this, you can do so like this:
5858
```
59-
$ docker run -p 8080:80 -v ./storage2:/storage2 -e API_BASE_PATH=/api/v3.1 -e STORAGE_TYPE=LOCAL_FILE_BACKEND -e STORAGE_PATH=/storage2 basyx-python-sdk-http-server
59+
$ docker run -p 8080:80 -v ./storage2:/storage2 -e API_BASE_PATH=/api/v3.1 -e STORAGE_TYPE=LOCAL_FILE_BACKEND -e STORAGE_PATH=/storage2 basyx-python-server
6060
```
6161

6262
## Building and running the image with docker-compose
@@ -70,7 +70,9 @@ This is the exemplary `docker-compose` file for the server:
7070
````yaml
7171
services:
7272
app:
73-
build: .
73+
build:
74+
context: ..
75+
dockerfile: server/Dockerfile
7476
ports:
7577
- "8080:80"
7678
volumes:

server/compose.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
services:
22
app:
3-
build: .
3+
build:
4+
context: ..
5+
dockerfile: server/Dockerfile
46
ports:
57
- "8080:80"
68
volumes:

0 commit comments

Comments
 (0)