diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f147c17..715769f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: release: types: [published] +env: + TARGET_PLATFORM: "linux/amd64,linux/arm/v7,linux/arm64" + jobs: sdk-publish: # This job publishes the package to PyPI @@ -59,7 +62,54 @@ jobs: with: password: ${{ secrets.PYPI_ORG_TOKEN }} - server-publish: + server-repository-publish: + # This job publishes the server docker image to DockerHub + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./server + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Extract Docker image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: eclipsebasyx/basyx-python-repository + # This fetches the latest git tag and adds an additional "latest" to it, so e.g. `2.1.0,latest` + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + labels: | + org.opencontainers.image.title=BaSyx Python Repository Service + org.opencontainers.image.description=Eclipse BaSyx Python SDK - Repository HTTP Server + org.opencontainers.image.source=https://github.com/eclipse-basyx/basyx-python-sdk/tree/main/server + org.opencontainers.image.licenses=MIT + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_HUB_USER }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Setup QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build and Push Repository Docker Image + uses: docker/build-push-action@v6 + with: + context: . + file: ./server/docker/repository/Dockerfile + platforms: ${{ env.TARGET_PLATFORM }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + server-discovery-publish: # This job publishes the server docker image to DockerHub runs-on: ubuntu-latest defaults: @@ -73,14 +123,14 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: eclipsebasyx/basyx-python-server + images: eclipsebasyx/basyx-python-discovery # This fetches the latest git tag and adds an additional "latest" to it, so e.g. `2.1.0,latest` tags: | type=semver,pattern={{version}} type=raw,value=latest labels: | - org.opencontainers.image.title=BaSyx Python Server - org.opencontainers.image.description=Eclipse BaSyx Python SDK - HTTP Server + org.opencontainers.image.title=BaSyx Python Discovery Service + org.opencontainers.image.description=Eclipse BaSyx Python SDK - Discovery HTTP Server org.opencontainers.image.source=https://github.com/eclipse-basyx/basyx-python-sdk/tree/main/server org.opencontainers.image.licenses=MIT @@ -90,11 +140,65 @@ jobs: username: ${{ secrets.DOCKER_HUB_USER }} password: ${{ secrets.DOCKER_HUB_TOKEN }} - - name: Build and Push Docker Image + - name: Setup QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build and Push Repository Docker Image + uses: docker/build-push-action@v6 + with: + context: . + file: ./server/docker/discovery/Dockerfile + platforms: ${{ env.TARGET_PLATFORM }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + server-registry-publish: + # This job publishes the server docker image to DockerHub + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./server + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Extract Docker image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: eclipsebasyx/basyx-python-registry + # This fetches the latest git tag and adds an additional "latest" to it, so e.g. `2.1.0,latest` + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + labels: | + org.opencontainers.image.title=BaSyx Python Registry Service + org.opencontainers.image.description=Eclipse BaSyx Python SDK - Registry HTTP Server + org.opencontainers.image.source=https://github.com/eclipse-basyx/basyx-python-sdk/tree/main/server + org.opencontainers.image.licenses=MIT + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_HUB_USER }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Setup QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build and Push Repository Docker Image uses: docker/build-push-action@v6 with: context: . - file: ./server/Dockerfile # Todo: Update paths + file: ./server/docker/registry/Dockerfile + platforms: ${{ env.TARGET_PLATFORM }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/compliance_tool/aas_compliance_tool/compliance_check_aasx.py b/compliance_tool/aas_compliance_tool/compliance_check_aasx.py index 0b10f5fe..40c4f2cd 100644 --- a/compliance_tool/aas_compliance_tool/compliance_check_aasx.py +++ b/compliance_tool/aas_compliance_tool/compliance_check_aasx.py @@ -88,69 +88,6 @@ def check_deserialization(file_path: str, state_manager: ComplianceToolStateMana return identifiable_store, files, new_cp -def check_schema(file_path: str, state_manager: ComplianceToolStateManager) -> None: - """ - Checks a given file against the official json schema and reports any issues using the given - :class:`~basyx.aas.compliance_tool.state_manager.ComplianceToolStateManager` - - Opens the file and checks if the data inside is stored in XML or JSON. Then calls the respective compliance tool - schema check - """ - logger = logging.getLogger('compliance_check') - logger.addHandler(state_manager) - logger.propagate = False - logger.setLevel(logging.INFO) - - # create handler to get logger info - logger_deserialization = logging.getLogger(aasx.__name__) - logger_deserialization.addHandler(state_manager) - logger_deserialization.propagate = False - logger_deserialization.setLevel(logging.INFO) - - state_manager.add_step('Open file') - try: - # open given file - reader = aasx.AASXReader(file_path) - state_manager.set_step_status_from_log() - except ValueError as error: - logger.error(error) - state_manager.set_step_status_from_log() - state_manager.add_step('Read file') - state_manager.set_step_status(Status.NOT_EXECUTED) - return - - try: - # read given file (Find XML and JSON parts) - state_manager.add_step('Read file') - core_rels = reader.reader.get_related_parts_by_type() - try: - aasx_origin_part = core_rels[aasx.RELATIONSHIP_TYPE_AASX_ORIGIN][0] - except IndexError as e: - raise ValueError("Not a valid AASX file: aasx-origin Relationship is missing.") from e - state_manager.set_step_status(Status.SUCCESS) - for aas_part in reader.reader.get_related_parts_by_type(aasx_origin_part)[ - aasx.RELATIONSHIP_TYPE_AAS_SPEC]: - content_type = reader.reader.get_content_type(aas_part) - extension = aas_part.split("/")[-1].split(".")[-1] - with reader.reader.open_part(aas_part) as p: - if content_type.split(";")[0] in ( - "text/xml", "application/xml") or content_type == "" and extension == "xml": - logger.debug("Parsing AAS objects from XML stream in OPC part {} ...".format(aas_part)) - compliance_check_xml._check_schema(p, state_manager) - elif content_type.split(";")[0] == "application/json" \ - or content_type == "" and extension == "json": - logger.debug("Parsing AAS objects from JSON stream in OPC part {} ...".format(aas_part)) - compliance_check_json._check_schema(io.TextIOWrapper(p, encoding='utf-8-sig'), state_manager) - else: - raise ValueError("Could not determine part format of AASX part {} (Content Type: {}, extension: {}" - .format(aas_part, content_type, extension)) - except ValueError as error: - logger.error(error) - state_manager.set_step_status(Status.FAILED) - finally: - reader.close() - - def check_aas_example(file_path: str, state_manager: ComplianceToolStateManager, **kwargs) -> None: """ Checks if a file contains all elements of the aas example and reports any issues using the given diff --git a/compliance_tool/aas_compliance_tool/compliance_check_json.py b/compliance_tool/aas_compliance_tool/compliance_check_json.py index b021fa96..e50332ec 100644 --- a/compliance_tool/aas_compliance_tool/compliance_check_json.py +++ b/compliance_tool/aas_compliance_tool/compliance_check_json.py @@ -23,84 +23,6 @@ from aas_compliance_tool.state_manager import ComplianceToolStateManager, Status -JSON_SCHEMA_FILE = os.path.join(os.path.dirname(__file__), 'schemas/aasJSONSchema.json') - - -def check_schema(file_path: str, state_manager: ComplianceToolStateManager) -> None: - """ - Checks a given file against the official json schema and reports any issues using the given - :class:`~basyx.aas.compliance_tool.state_manager.ComplianceToolStateManager` - - Add the steps: `Open file`, `Read file and check if it is conform to the json syntax` and `Validate file against - official json schema` - - :param file_path: Path to the file which should be checked - :param state_manager: :class:`~basyx.aas.compliance_tool.state_manager.ComplianceToolStateManager` to log the steps - """ - logger = logging.getLogger('compliance_check') - logger.addHandler(state_manager) - logger.propagate = False - logger.setLevel(logging.INFO) - - state_manager.add_step('Open file') - try: - # open given file - file_to_be_checked = open(file_path, 'r', encoding='utf-8-sig') - except IOError as error: - state_manager.set_step_status(Status.FAILED) - logger.error(error) - state_manager.add_step('Read file and check if it is conform to the json syntax') - state_manager.set_step_status(Status.NOT_EXECUTED) - state_manager.add_step('Validate file against official json schema') - state_manager.set_step_status(Status.NOT_EXECUTED) - return - return _check_schema(file_to_be_checked, state_manager) - - -def _check_schema(file_to_be_checked: IO[str], state_manager: ComplianceToolStateManager): - logger = logging.getLogger('compliance_check') - logger.addHandler(state_manager) - logger.propagate = False - logger.setLevel(logging.INFO) - - try: - with file_to_be_checked: - state_manager.set_step_status(Status.SUCCESS) - # read given file and check if it is conform to the json syntax - state_manager.add_step('Read file and check if it is conform to the json syntax') - json_to_be_checked = json.load(file_to_be_checked) - state_manager.set_step_status(Status.SUCCESS) - except json.decoder.JSONDecodeError as error: - state_manager.set_step_status(Status.FAILED) - logger.error(error) - state_manager.add_step('Validate file against official json schema') - state_manager.set_step_status(Status.NOT_EXECUTED) - return - - # load json schema - with open(JSON_SCHEMA_FILE, 'r', encoding='utf-8-sig') as json_file: - aas_json_schema = json.load(json_file) - state_manager.add_step('Validate file against official json schema') - - # validate given file against schema - try: - import jsonschema # type: ignore - except ImportError as error: - state_manager.set_step_status(Status.NOT_EXECUTED) - logger.error("Python package 'jsonschema' is required for validating the JSON file.", error) - return - - try: - jsonschema.validate(instance=json_to_be_checked, schema=aas_json_schema) - except jsonschema.exceptions.ValidationError as error: - state_manager.set_step_status(Status.FAILED) - logger.error(error) - return - - state_manager.set_step_status(Status.SUCCESS) - return - - def check_deserialization(file_path: str, state_manager: ComplianceToolStateManager, file_info: Optional[str] = None) -> model.DictIdentifiableStore: """ diff --git a/compliance_tool/aas_compliance_tool/compliance_check_xml.py b/compliance_tool/aas_compliance_tool/compliance_check_xml.py index 81f2b5ff..eeb9924c 100644 --- a/compliance_tool/aas_compliance_tool/compliance_check_xml.py +++ b/compliance_tool/aas_compliance_tool/compliance_check_xml.py @@ -23,83 +23,6 @@ from aas_compliance_tool.state_manager import ComplianceToolStateManager, Status -XML_SCHEMA_FILE = os.path.join(os.path.dirname(__file__), 'schemas/aasXMLSchema.xsd') - - -def check_schema(file_path: str, state_manager: ComplianceToolStateManager) -> None: - """ - Checks a given file against the official xml schema and reports any issues using the given - :class:`~basyx.aas.compliance_tool.state_manager.ComplianceToolStateManager` - - Add the steps: `Open file`, `Read file`, `Check if it is conform to the xml syntax` and `Validate file against - official xml schema` - - :param file_path: Path to the file which should be checked - :param state_manager: :class:`~basyx.aas.compliance_tool.state_manager.ComplianceToolStateManager` to log the steps - """ - logger = logging.getLogger('compliance_check') - logger.addHandler(state_manager) - logger.propagate = False - logger.setLevel(logging.INFO) - - state_manager.add_step('Open file') - try: - # open given file - file_to_be_checked = open(file_path, 'rb') - state_manager.set_step_status(Status.SUCCESS) - except IOError as error: - state_manager.set_step_status(Status.FAILED) - logger.error(error) - state_manager.add_step('Read file and check if it is conform to the xml syntax') - state_manager.set_step_status(Status.NOT_EXECUTED) - state_manager.add_step('Validate file against official xml schema') - state_manager.set_step_status(Status.NOT_EXECUTED) - return - return _check_schema(file_to_be_checked, state_manager) - - -def _check_schema(file_to_be_checked, state_manager): - logger = logging.getLogger('compliance_check') - logger.addHandler(state_manager) - logger.propagate = False - logger.setLevel(logging.INFO) - - state_manager.add_step('Read file and check if it is conform to the xml syntax') - try: - # read given file and check if it is conform to the xml syntax - parser = etree.XMLParser(remove_blank_text=True, remove_comments=True) - etree.parse(file_to_be_checked, parser) - state_manager.set_step_status(Status.SUCCESS) - except etree.XMLSyntaxError as error: - state_manager.set_step_status(Status.FAILED) - logger.error(error) - state_manager.add_step('Validate file against official xml schema') - state_manager.set_step_status(Status.NOT_EXECUTED) - file_to_be_checked.close() - return - except Exception: - file_to_be_checked.close() - raise - - # load aas xml schema - aas_xml_schema = etree.XMLSchema(file=XML_SCHEMA_FILE) - parser = etree.XMLParser(schema=aas_xml_schema) - - state_manager.add_step('Validate file against official xml schema') - # validate given file against schema - try: - file_to_be_checked.seek(0) # Reset reading file offset (cursor) to the beginning of the file - with file_to_be_checked: - etree.parse(file_to_be_checked, parser=parser) - except etree.ParseError as error: - state_manager.set_step_status(Status.FAILED) - logger.error(error) - return - - state_manager.set_step_status(Status.SUCCESS) - return - - def check_deserialization(file_path: str, state_manager: ComplianceToolStateManager, file_info: Optional[str] = None) -> model.DictIdentifiableStore: """ diff --git a/compliance_tool/aas_compliance_tool/schemas/aasJSONSchema.json b/compliance_tool/aas_compliance_tool/schemas/aasJSONSchema.json deleted file mode 100644 index 7ba1a360..00000000 --- a/compliance_tool/aas_compliance_tool/schemas/aasJSONSchema.json +++ /dev/null @@ -1,1528 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2019-09/schema", - "title": "AssetAdministrationShellEnvironment", - "type": "object", - "allOf": [ - { - "$ref": "#/definitions/Environment" - } - ], - "$id": "https://admin-shell.io/aas/3/1", - "definitions": { - "AasSubmodelElements": { - "type": "string", - "enum": [ - "AnnotatedRelationshipElement", - "BasicEventElement", - "Blob", - "Capability", - "DataElement", - "Entity", - "EventElement", - "File", - "MultiLanguageProperty", - "Operation", - "Property", - "Range", - "ReferenceElement", - "RelationshipElement", - "SubmodelElement", - "SubmodelElementCollection", - "SubmodelElementList" - ] - }, - "AbstractLangString": { - "type": "object", - "properties": { - "language": { - "type": "string", - "pattern": "^(([a-zA-Z]{2,3}(-[a-zA-Z]{3}(-[a-zA-Z]{3}){2})?|[a-zA-Z]{4}|[a-zA-Z]{5,8})(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-(([a-zA-Z0-9]){5,8}|[0-9]([a-zA-Z0-9]){3}))*(-[0-9A-WY-Za-wy-z](-([a-zA-Z0-9]){2,8})+)*(-[xX](-([a-zA-Z0-9]){1,8})+)?|[xX](-([a-zA-Z0-9]){1,8})+|((en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang)))$" - }, - "text": { - "type": "string", - "minLength": 1, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - } - }, - "required": [ - "language", - "text" - ] - }, - "AdministrativeInformation": { - "allOf": [ - { - "$ref": "#/definitions/HasDataSpecification" - }, - { - "properties": { - "version": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 4 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^(0|[1-9][0-9]*)$" - } - ] - }, - "revision": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 4 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^(0|[1-9][0-9]*)$" - } - ] - }, - "creator": { - "$ref": "#/definitions/Reference" - }, - "templateId": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - } - } - } - ] - }, - "AnnotatedRelationshipElement": { - "allOf": [ - { - "$ref": "#/definitions/RelationshipElement_abstract" - }, - { - "properties": { - "annotations": { - "type": "array", - "items": { - "$ref": "#/definitions/DataElement_choice" - }, - "minItems": 1 - }, - "modelType": { - "const": "AnnotatedRelationshipElement" - } - } - } - ] - }, - "AssetAdministrationShell": { - "allOf": [ - { - "$ref": "#/definitions/Identifiable" - }, - { - "$ref": "#/definitions/HasDataSpecification" - }, - { - "properties": { - "derivedFrom": { - "$ref": "#/definitions/Reference" - }, - "assetInformation": { - "$ref": "#/definitions/AssetInformation" - }, - "submodels": { - "type": "array", - "items": { - "$ref": "#/definitions/Reference" - }, - "minItems": 1 - }, - "modelType": { - "const": "AssetAdministrationShell" - } - }, - "required": [ - "assetInformation" - ] - } - ] - }, - "AssetInformation": { - "type": "object", - "properties": { - "assetKind": { - "$ref": "#/definitions/AssetKind" - }, - "globalAssetId": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "specificAssetIds": { - "type": "array", - "items": { - "$ref": "#/definitions/SpecificAssetId" - }, - "minItems": 1 - }, - "assetType": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "defaultThumbnail": { - "$ref": "#/definitions/Resource" - } - }, - "required": [ - "assetKind" - ] - }, - "AssetKind": { - "type": "string", - "enum": [ - "Instance", - "NotApplicable", - "Type" - ] - }, - "BasicEventElement": { - "allOf": [ - { - "$ref": "#/definitions/EventElement" - }, - { - "properties": { - "observed": { - "$ref": "#/definitions/Reference" - }, - "direction": { - "$ref": "#/definitions/Direction" - }, - "state": { - "$ref": "#/definitions/StateOfEvent" - }, - "messageTopic": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "messageBroker": { - "$ref": "#/definitions/Reference" - }, - "lastUpdate": { - "type": "string", - "pattern": "^-?(([1-9][0-9][0-9][0-9]+)|(0[0-9][0-9][0-9]))-((0[1-9])|(1[0-2]))-((0[1-9])|([12][0-9])|(3[01]))T(((([01][0-9])|(2[0-3])):[0-5][0-9]:([0-5][0-9])(\\.[0-9]+)?)|24:00:00(\\.0+)?)(Z|\\+00:00|-00:00)$" - }, - "minInterval": { - "type": "string", - "pattern": "^-?P((([0-9]+Y([0-9]+M)?([0-9]+D)?|([0-9]+M)([0-9]+D)?|([0-9]+D))(T(([0-9]+H)([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|([0-9]+M)([0-9]+(\\.[0-9]+)?S)?|([0-9]+(\\.[0-9]+)?S)))?)|(T(([0-9]+H)([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|([0-9]+M)([0-9]+(\\.[0-9]+)?S)?|([0-9]+(\\.[0-9]+)?S))))$" - }, - "maxInterval": { - "type": "string", - "pattern": "^-?P((([0-9]+Y([0-9]+M)?([0-9]+D)?|([0-9]+M)([0-9]+D)?|([0-9]+D))(T(([0-9]+H)([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|([0-9]+M)([0-9]+(\\.[0-9]+)?S)?|([0-9]+(\\.[0-9]+)?S)))?)|(T(([0-9]+H)([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|([0-9]+M)([0-9]+(\\.[0-9]+)?S)?|([0-9]+(\\.[0-9]+)?S))))$" - }, - "modelType": { - "const": "BasicEventElement" - } - }, - "required": [ - "observed", - "direction", - "state" - ] - } - ] - }, - "Blob": { - "allOf": [ - { - "$ref": "#/definitions/DataElement" - }, - { - "properties": { - "value": { - "type": "string", - "contentEncoding": "base64" - }, - "contentType": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 100 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+/([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+([ \\t]*;[ \\t]*([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+=(([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+|\"(([\\t !#-\\[\\]-~]|[\u0080-\u00ff])|\\\\([\\t !-~]|[\u0080-\u00ff]))*\"))*$" - } - ] - }, - "modelType": { - "const": "Blob" - } - }, - "required": [ - "contentType" - ] - } - ] - }, - "Capability": { - "allOf": [ - { - "$ref": "#/definitions/SubmodelElement" - }, - { - "properties": { - "modelType": { - "const": "Capability" - } - } - } - ] - }, - "ConceptDescription": { - "allOf": [ - { - "$ref": "#/definitions/Identifiable" - }, - { - "$ref": "#/definitions/HasDataSpecification" - }, - { - "properties": { - "isCaseOf": { - "type": "array", - "items": { - "$ref": "#/definitions/Reference" - }, - "minItems": 1 - }, - "modelType": { - "const": "ConceptDescription" - } - } - } - ] - }, - "DataElement": { - "$ref": "#/definitions/SubmodelElement" - }, - "DataElement_choice": { - "oneOf": [ - { - "$ref": "#/definitions/Blob" - }, - { - "$ref": "#/definitions/File" - }, - { - "$ref": "#/definitions/MultiLanguageProperty" - }, - { - "$ref": "#/definitions/Property" - }, - { - "$ref": "#/definitions/Range" - }, - { - "$ref": "#/definitions/ReferenceElement" - } - ] - }, - "DataSpecificationContent": { - "type": "object", - "properties": { - "modelType": { - "$ref": "#/definitions/ModelType" - } - }, - "required": [ - "modelType" - ] - }, - "DataSpecificationContent_choice": { - "oneOf": [ - { - "$ref": "#/definitions/DataSpecificationIec61360" - } - ] - }, - "DataSpecificationIec61360": { - "allOf": [ - { - "$ref": "#/definitions/DataSpecificationContent" - }, - { - "properties": { - "preferredName": { - "type": "array", - "items": { - "$ref": "#/definitions/LangStringPreferredNameTypeIec61360" - }, - "minItems": 1 - }, - "shortName": { - "type": "array", - "items": { - "$ref": "#/definitions/LangStringShortNameTypeIec61360" - }, - "minItems": 1 - }, - "unit": { - "type": "string", - "minLength": 1, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "unitId": { - "$ref": "#/definitions/Reference" - }, - "sourceOfDefinition": { - "type": "string", - "minLength": 1, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "symbol": { - "type": "string", - "minLength": 1, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "dataType": { - "$ref": "#/definitions/DataTypeIec61360" - }, - "definition": { - "type": "array", - "items": { - "$ref": "#/definitions/LangStringDefinitionTypeIec61360" - }, - "minItems": 1 - }, - "valueFormat": { - "type": "string", - "minLength": 1, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "valueList": { - "$ref": "#/definitions/ValueList" - }, - "value": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "levelType": { - "$ref": "#/definitions/LevelType" - }, - "modelType": { - "const": "DataSpecificationIec61360" - } - }, - "required": [ - "preferredName" - ] - } - ] - }, - "DataTypeDefXsd": { - "type": "string", - "enum": [ - "xs:anyURI", - "xs:base64Binary", - "xs:boolean", - "xs:byte", - "xs:date", - "xs:dateTime", - "xs:decimal", - "xs:double", - "xs:duration", - "xs:float", - "xs:gDay", - "xs:gMonth", - "xs:gMonthDay", - "xs:gYear", - "xs:gYearMonth", - "xs:hexBinary", - "xs:int", - "xs:integer", - "xs:long", - "xs:negativeInteger", - "xs:nonNegativeInteger", - "xs:nonPositiveInteger", - "xs:positiveInteger", - "xs:short", - "xs:string", - "xs:time", - "xs:unsignedByte", - "xs:unsignedInt", - "xs:unsignedLong", - "xs:unsignedShort" - ] - }, - "DataTypeIec61360": { - "type": "string", - "enum": [ - "BLOB", - "BOOLEAN", - "DATE", - "FILE", - "HTML", - "INTEGER_COUNT", - "INTEGER_CURRENCY", - "INTEGER_MEASURE", - "IRDI", - "IRI", - "RATIONAL", - "RATIONAL_MEASURE", - "REAL_COUNT", - "REAL_CURRENCY", - "REAL_MEASURE", - "STRING", - "STRING_TRANSLATABLE", - "TIME", - "TIMESTAMP" - ] - }, - "Direction": { - "type": "string", - "enum": [ - "input", - "output" - ] - }, - "EmbeddedDataSpecification": { - "type": "object", - "properties": { - "dataSpecification": { - "$ref": "#/definitions/Reference" - }, - "dataSpecificationContent": { - "$ref": "#/definitions/DataSpecificationContent_choice" - } - }, - "required": [ - "dataSpecification", - "dataSpecificationContent" - ] - }, - "Entity": { - "allOf": [ - { - "$ref": "#/definitions/SubmodelElement" - }, - { - "properties": { - "statements": { - "type": "array", - "items": { - "$ref": "#/definitions/SubmodelElement_choice" - }, - "minItems": 1 - }, - "entityType": { - "$ref": "#/definitions/EntityType" - }, - "globalAssetId": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "specificAssetIds": { - "type": "array", - "items": { - "$ref": "#/definitions/SpecificAssetId" - }, - "minItems": 1 - }, - "modelType": { - "const": "Entity" - } - }, - "required": [ - "entityType" - ] - } - ] - }, - "EntityType": { - "type": "string", - "enum": [ - "CoManagedEntity", - "SelfManagedEntity" - ] - }, - "Environment": { - "type": "object", - "properties": { - "assetAdministrationShells": { - "type": "array", - "items": { - "$ref": "#/definitions/AssetAdministrationShell" - }, - "minItems": 1 - }, - "submodels": { - "type": "array", - "items": { - "$ref": "#/definitions/Submodel" - }, - "minItems": 1 - }, - "conceptDescriptions": { - "type": "array", - "items": { - "$ref": "#/definitions/ConceptDescription" - }, - "minItems": 1 - } - } - }, - "EventElement": { - "$ref": "#/definitions/SubmodelElement" - }, - "EventPayload": { - "type": "object", - "properties": { - "source": { - "$ref": "#/definitions/Reference" - }, - "sourceSemanticId": { - "$ref": "#/definitions/Reference" - }, - "observableReference": { - "$ref": "#/definitions/Reference" - }, - "observableSemanticId": { - "$ref": "#/definitions/Reference" - }, - "topic": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "subjectId": { - "$ref": "#/definitions/Reference" - }, - "timeStamp": { - "type": "string", - "pattern": "^-?(([1-9][0-9][0-9][0-9]+)|(0[0-9][0-9][0-9]))-((0[1-9])|(1[0-2]))-((0[1-9])|([12][0-9])|(3[01]))T(((([01][0-9])|(2[0-3])):[0-5][0-9]:([0-5][0-9])(\\.[0-9]+)?)|24:00:00(\\.0+)?)(Z|\\+00:00|-00:00)$" - }, - "payload": { - "type": "string", - "contentEncoding": "base64" - } - }, - "required": [ - "source", - "observableReference", - "timeStamp" - ] - }, - "Extension": { - "allOf": [ - { - "$ref": "#/definitions/HasSemantics" - }, - { - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "valueType": { - "$ref": "#/definitions/DataTypeDefXsd" - }, - "value": { - "type": "string" - }, - "refersTo": { - "type": "array", - "items": { - "$ref": "#/definitions/Reference" - }, - "minItems": 1 - } - }, - "required": [ - "name" - ] - } - ] - }, - "File": { - "allOf": [ - { - "$ref": "#/definitions/DataElement" - }, - { - "properties": { - "value": { - "type": "string" - }, - "contentType": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 100 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+/([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+([ \\t]*;[ \\t]*([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+=(([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+|\"(([\\t !#-\\[\\]-~]|[\u0080-\u00ff])|\\\\([\\t !-~]|[\u0080-\u00ff]))*\"))*$" - } - ] - }, - "modelType": { - "const": "File" - } - }, - "required": [ - "contentType" - ] - } - ] - }, - "HasDataSpecification": { - "type": "object", - "properties": { - "embeddedDataSpecifications": { - "type": "array", - "items": { - "$ref": "#/definitions/EmbeddedDataSpecification" - }, - "minItems": 1 - } - } - }, - "HasExtensions": { - "type": "object", - "properties": { - "extensions": { - "type": "array", - "items": { - "$ref": "#/definitions/Extension" - }, - "minItems": 1 - } - } - }, - "HasKind": { - "type": "object", - "properties": { - "kind": { - "$ref": "#/definitions/ModellingKind" - } - } - }, - "HasSemantics": { - "type": "object", - "properties": { - "semanticId": { - "$ref": "#/definitions/Reference" - }, - "supplementalSemanticIds": { - "type": "array", - "items": { - "$ref": "#/definitions/Reference" - }, - "minItems": 1 - } - } - }, - "Identifiable": { - "allOf": [ - { - "$ref": "#/definitions/Referable" - }, - { - "properties": { - "administration": { - "$ref": "#/definitions/AdministrativeInformation" - }, - "id": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - } - }, - "required": [ - "id" - ] - } - ] - }, - "Key": { - "type": "object", - "properties": { - "type": { - "$ref": "#/definitions/KeyTypes" - }, - "value": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - } - }, - "required": [ - "type", - "value" - ] - }, - "KeyTypes": { - "type": "string", - "enum": [ - "AnnotatedRelationshipElement", - "AssetAdministrationShell", - "BasicEventElement", - "Blob", - "Capability", - "ConceptDescription", - "DataElement", - "Entity", - "EventElement", - "File", - "FragmentReference", - "GlobalReference", - "Identifiable", - "MultiLanguageProperty", - "Operation", - "Property", - "Range", - "Referable", - "ReferenceElement", - "RelationshipElement", - "Submodel", - "SubmodelElement", - "SubmodelElementCollection", - "SubmodelElementList" - ] - }, - "LangStringDefinitionTypeIec61360": { - "allOf": [ - { - "$ref": "#/definitions/AbstractLangString" - }, - { - "properties": { - "text": { - "maxLength": 1023 - } - } - } - ] - }, - "LangStringNameType": { - "allOf": [ - { - "$ref": "#/definitions/AbstractLangString" - }, - { - "properties": { - "text": { - "maxLength": 128 - } - } - } - ] - }, - "LangStringPreferredNameTypeIec61360": { - "allOf": [ - { - "$ref": "#/definitions/AbstractLangString" - }, - { - "properties": { - "text": { - "maxLength": 255 - } - } - } - ] - }, - "LangStringShortNameTypeIec61360": { - "allOf": [ - { - "$ref": "#/definitions/AbstractLangString" - }, - { - "properties": { - "text": { - "maxLength": 18 - } - } - } - ] - }, - "LangStringTextType": { - "allOf": [ - { - "$ref": "#/definitions/AbstractLangString" - }, - { - "properties": { - "text": { - "maxLength": 1023 - } - } - } - ] - }, - "LevelType": { - "type": "object", - "properties": { - "min": { - "type": "boolean" - }, - "nom": { - "type": "boolean" - }, - "typ": { - "type": "boolean" - }, - "max": { - "type": "boolean" - } - }, - "required": [ - "min", - "nom", - "typ", - "max" - ] - }, - "ModelType": { - "type": "string", - "enum": [ - "AnnotatedRelationshipElement", - "AssetAdministrationShell", - "BasicEventElement", - "Blob", - "Capability", - "ConceptDescription", - "DataSpecificationIec61360", - "Entity", - "File", - "MultiLanguageProperty", - "Operation", - "Property", - "Range", - "ReferenceElement", - "RelationshipElement", - "Submodel", - "SubmodelElementCollection", - "SubmodelElementList" - ] - }, - "ModellingKind": { - "type": "string", - "enum": [ - "Instance", - "Template" - ] - }, - "MultiLanguageProperty": { - "allOf": [ - { - "$ref": "#/definitions/DataElement" - }, - { - "properties": { - "value": { - "type": "array", - "items": { - "$ref": "#/definitions/LangStringTextType" - }, - "minItems": 1 - }, - "valueId": { - "$ref": "#/definitions/Reference" - }, - "modelType": { - "const": "MultiLanguageProperty" - } - } - } - ] - }, - "Operation": { - "allOf": [ - { - "$ref": "#/definitions/SubmodelElement" - }, - { - "properties": { - "inputVariables": { - "type": "array", - "items": { - "$ref": "#/definitions/OperationVariable" - }, - "minItems": 1 - }, - "outputVariables": { - "type": "array", - "items": { - "$ref": "#/definitions/OperationVariable" - }, - "minItems": 1 - }, - "inoutputVariables": { - "type": "array", - "items": { - "$ref": "#/definitions/OperationVariable" - }, - "minItems": 1 - }, - "modelType": { - "const": "Operation" - } - } - } - ] - }, - "OperationVariable": { - "type": "object", - "properties": { - "value": { - "$ref": "#/definitions/SubmodelElement_choice" - } - }, - "required": [ - "value" - ] - }, - "Property": { - "allOf": [ - { - "$ref": "#/definitions/DataElement" - }, - { - "properties": { - "valueType": { - "$ref": "#/definitions/DataTypeDefXsd" - }, - "value": { - "type": "string" - }, - "valueId": { - "$ref": "#/definitions/Reference" - }, - "modelType": { - "const": "Property" - } - }, - "required": [ - "valueType" - ] - } - ] - }, - "Qualifiable": { - "type": "object", - "properties": { - "qualifiers": { - "type": "array", - "items": { - "$ref": "#/definitions/Qualifier" - }, - "minItems": 1 - }, - "modelType": { - "$ref": "#/definitions/ModelType" - } - }, - "required": [ - "modelType" - ] - }, - "Qualifier": { - "allOf": [ - { - "$ref": "#/definitions/HasSemantics" - }, - { - "properties": { - "kind": { - "$ref": "#/definitions/QualifierKind" - }, - "type": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "valueType": { - "$ref": "#/definitions/DataTypeDefXsd" - }, - "value": { - "type": "string" - }, - "valueId": { - "$ref": "#/definitions/Reference" - } - }, - "required": [ - "type", - "valueType" - ] - } - ] - }, - "QualifierKind": { - "type": "string", - "enum": [ - "ConceptQualifier", - "TemplateQualifier", - "ValueQualifier" - ] - }, - "Range": { - "allOf": [ - { - "$ref": "#/definitions/DataElement" - }, - { - "properties": { - "valueType": { - "$ref": "#/definitions/DataTypeDefXsd" - }, - "min": { - "type": "string" - }, - "max": { - "type": "string" - }, - "modelType": { - "const": "Range" - } - }, - "required": [ - "valueType" - ] - } - ] - }, - "Referable": { - "allOf": [ - { - "$ref": "#/definitions/HasExtensions" - }, - { - "properties": { - "category": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "idShort": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 128 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^[a-zA-Z][a-zA-Z0-9_]*$" - } - ] - }, - "displayName": { - "type": "array", - "items": { - "$ref": "#/definitions/LangStringNameType" - }, - "minItems": 1 - }, - "description": { - "type": "array", - "items": { - "$ref": "#/definitions/LangStringTextType" - }, - "minItems": 1 - }, - "modelType": { - "$ref": "#/definitions/ModelType" - } - }, - "required": [ - "modelType" - ] - } - ] - }, - "Reference": { - "type": "object", - "properties": { - "type": { - "$ref": "#/definitions/ReferenceTypes" - }, - "referredSemanticId": { - "$ref": "#/definitions/Reference" - }, - "keys": { - "type": "array", - "items": { - "$ref": "#/definitions/Key" - }, - "minItems": 1 - } - }, - "required": [ - "type", - "keys" - ] - }, - "ReferenceElement": { - "allOf": [ - { - "$ref": "#/definitions/DataElement" - }, - { - "properties": { - "value": { - "$ref": "#/definitions/Reference" - }, - "modelType": { - "const": "ReferenceElement" - } - } - } - ] - }, - "ReferenceTypes": { - "type": "string", - "enum": [ - "ExternalReference", - "ModelReference" - ] - }, - "RelationshipElement": { - "allOf": [ - { - "$ref": "#/definitions/RelationshipElement_abstract" - }, - { - "properties": { - "modelType": { - "const": "RelationshipElement" - } - } - } - ] - }, - "RelationshipElement_abstract": { - "allOf": [ - { - "$ref": "#/definitions/SubmodelElement" - }, - { - "properties": { - "first": { - "$ref": "#/definitions/Reference" - }, - "second": { - "$ref": "#/definitions/Reference" - } - }, - "required": [ - "first", - "second" - ] - } - ] - }, - "RelationshipElement_choice": { - "oneOf": [ - { - "$ref": "#/definitions/RelationshipElement" - }, - { - "$ref": "#/definitions/AnnotatedRelationshipElement" - } - ] - }, - "Resource": { - "type": "object", - "properties": { - "path": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 2000 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^file:(//((localhost|(\\[((([0-9A-Fa-f]{1,4}:){6}([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::([0-9A-Fa-f]{1,4}:){5}([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|([0-9A-Fa-f]{1,4})?::([0-9A-Fa-f]{1,4}:){4}([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(([0-9A-Fa-f]{1,4}:)?[0-9A-Fa-f]{1,4})?::([0-9A-Fa-f]{1,4}:){3}([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(([0-9A-Fa-f]{1,4}:){2}[0-9A-Fa-f]{1,4})?::([0-9A-Fa-f]{1,4}:){2}([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(([0-9A-Fa-f]{1,4}:){3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(([0-9A-Fa-f]{1,4}:){4}[0-9A-Fa-f]{1,4})?::([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(([0-9A-Fa-f]{1,4}:){5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(([0-9A-Fa-f]{1,4}:){6}[0-9A-Fa-f]{1,4})?::)|[vV][0-9A-Fa-f]+\\.([a-zA-Z0-9\\-._~]|[!$&'()*+,;=]|:)+)\\]|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|([a-zA-Z0-9\\-._~]|%[0-9A-Fa-f][0-9A-Fa-f]|[!$&'()*+,;=])*)))?/((([a-zA-Z0-9\\-._~]|%[0-9A-Fa-f][0-9A-Fa-f]|[!$&'()*+,;=]|[:@]))+(/(([a-zA-Z0-9\\-._~]|%[0-9A-Fa-f][0-9A-Fa-f]|[!$&'()*+,;=]|[:@]))*)*)?|/((([a-zA-Z0-9\\-._~]|%[0-9A-Fa-f][0-9A-Fa-f]|[!$&'()*+,;=]|[:@]))+(/(([a-zA-Z0-9\\-._~]|%[0-9A-Fa-f][0-9A-Fa-f]|[!$&'()*+,;=]|[:@]))*)*)?)$" - } - ] - }, - "contentType": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 100 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+/([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+([ \\t]*;[ \\t]*([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+=(([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+|\"(([\\t !#-\\[\\]-~]|[\u0080-\u00ff])|\\\\([\\t !-~]|[\u0080-\u00ff]))*\"))*$" - } - ] - } - }, - "required": [ - "path" - ] - }, - "SpecificAssetId": { - "allOf": [ - { - "$ref": "#/definitions/HasSemantics" - }, - { - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "value": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "externalSubjectId": { - "$ref": "#/definitions/Reference" - } - }, - "required": [ - "name", - "value" - ] - } - ] - }, - "StateOfEvent": { - "type": "string", - "enum": [ - "off", - "on" - ] - }, - "Submodel": { - "allOf": [ - { - "$ref": "#/definitions/Identifiable" - }, - { - "$ref": "#/definitions/HasKind" - }, - { - "$ref": "#/definitions/HasSemantics" - }, - { - "$ref": "#/definitions/Qualifiable" - }, - { - "$ref": "#/definitions/HasDataSpecification" - }, - { - "properties": { - "submodelElements": { - "type": "array", - "items": { - "$ref": "#/definitions/SubmodelElement_choice" - }, - "minItems": 1 - }, - "modelType": { - "const": "Submodel" - } - } - } - ] - }, - "SubmodelElement": { - "allOf": [ - { - "$ref": "#/definitions/Referable" - }, - { - "$ref": "#/definitions/HasSemantics" - }, - { - "$ref": "#/definitions/Qualifiable" - }, - { - "$ref": "#/definitions/HasDataSpecification" - } - ] - }, - "SubmodelElementCollection": { - "allOf": [ - { - "$ref": "#/definitions/SubmodelElement" - }, - { - "properties": { - "value": { - "type": "array", - "items": { - "$ref": "#/definitions/SubmodelElement_choice" - }, - "minItems": 1 - }, - "modelType": { - "const": "SubmodelElementCollection" - } - } - } - ] - }, - "SubmodelElementList": { - "allOf": [ - { - "$ref": "#/definitions/SubmodelElement" - }, - { - "properties": { - "orderRelevant": { - "type": "boolean" - }, - "semanticIdListElement": { - "$ref": "#/definitions/Reference" - }, - "typeValueListElement": { - "$ref": "#/definitions/AasSubmodelElements" - }, - "valueTypeListElement": { - "$ref": "#/definitions/DataTypeDefXsd" - }, - "value": { - "type": "array", - "items": { - "$ref": "#/definitions/SubmodelElement_choice" - }, - "minItems": 1 - }, - "modelType": { - "const": "SubmodelElementList" - } - }, - "required": [ - "typeValueListElement" - ] - } - ] - }, - "SubmodelElement_choice": { - "oneOf": [ - { - "$ref": "#/definitions/RelationshipElement" - }, - { - "$ref": "#/definitions/AnnotatedRelationshipElement" - }, - { - "$ref": "#/definitions/BasicEventElement" - }, - { - "$ref": "#/definitions/Blob" - }, - { - "$ref": "#/definitions/Capability" - }, - { - "$ref": "#/definitions/Entity" - }, - { - "$ref": "#/definitions/File" - }, - { - "$ref": "#/definitions/MultiLanguageProperty" - }, - { - "$ref": "#/definitions/Operation" - }, - { - "$ref": "#/definitions/Property" - }, - { - "$ref": "#/definitions/Range" - }, - { - "$ref": "#/definitions/ReferenceElement" - }, - { - "$ref": "#/definitions/SubmodelElementCollection" - }, - { - "$ref": "#/definitions/SubmodelElementList" - } - ] - }, - "ValueList": { - "type": "object", - "properties": { - "valueReferencePairs": { - "type": "array", - "items": { - "$ref": "#/definitions/ValueReferencePair" - }, - "minItems": 1 - } - }, - "required": [ - "valueReferencePairs" - ] - }, - "ValueReferencePair": { - "type": "object", - "properties": { - "value": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "valueId": { - "$ref": "#/definitions/Reference" - } - }, - "required": [ - "value", - "valueId" - ] - } - } -} \ No newline at end of file diff --git a/compliance_tool/aas_compliance_tool/schemas/aasXMLSchema.xsd b/compliance_tool/aas_compliance_tool/schemas/aasXMLSchema.xsd deleted file mode 100644 index 95096ecb..00000000 --- a/compliance_tool/aas_compliance_tool/schemas/aasXMLSchema.xsd +++ /dev/null @@ -1,1344 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/compliance_tool/test/test_compliance_check_json.py b/compliance_tool/test/test_compliance_check_json.py index a63d3909..656d1e50 100644 --- a/compliance_tool/test/test_compliance_check_json.py +++ b/compliance_tool/test/test_compliance_check_json.py @@ -12,50 +12,6 @@ class ComplianceToolJsonTest(unittest.TestCase): - def test_check_schema(self) -> None: - manager = ComplianceToolStateManager() - script_dir = os.path.dirname(__file__) - file_path_1 = os.path.join(script_dir, 'files/test_not_found.json') - compliance_tool.check_schema(file_path_1, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.FAILED, manager.steps[0].status) - self.assertEqual(Status.NOT_EXECUTED, manager.steps[1].status) - self.assertEqual(Status.NOT_EXECUTED, manager.steps[2].status) - self.assertIn("No such file or directory", manager.format_step(0, verbose_level=1)) - - manager.steps = [] - file_path_2 = os.path.join(script_dir, 'files/test_not_deserializable.json') - compliance_tool.check_schema(file_path_2, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.FAILED, manager.steps[1].status) - self.assertEqual(Status.NOT_EXECUTED, manager.steps[2].status) - self.assertIn("Expecting ',' delimiter: line 4 column 2 (char 54)", manager.format_step(1, verbose_level=1)) - - manager.steps = [] - file_path_3 = os.path.join(script_dir, 'files/test_empty.json') - compliance_tool.check_schema(file_path_3, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.SUCCESS, manager.steps[1].status) - self.assertEqual(Status.SUCCESS, manager.steps[2].status) - - manager.steps = [] - file_path_4 = os.path.join(script_dir, 'files/test_demo_full_example.json') - compliance_tool.check_schema(file_path_4, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.SUCCESS, manager.steps[1].status) - self.assertEqual(Status.SUCCESS, manager.steps[2].status) - - manager.steps = [] - file_path_5 = os.path.join(script_dir, 'files/test_demo_full_example_wrong_attribute.json') - compliance_tool.check_schema(file_path_5, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.SUCCESS, manager.steps[1].status) - self.assertEqual(Status.SUCCESS, manager.steps[2].status) - def test_check_deserialization(self) -> None: manager = ComplianceToolStateManager() script_dir = os.path.dirname(__file__) diff --git a/compliance_tool/test/test_compliance_check_xml.py b/compliance_tool/test/test_compliance_check_xml.py index c7b023cc..7f5fbecc 100644 --- a/compliance_tool/test/test_compliance_check_xml.py +++ b/compliance_tool/test/test_compliance_check_xml.py @@ -12,41 +12,6 @@ class ComplianceToolXmlTest(unittest.TestCase): - def test_check_schema(self) -> None: - manager = ComplianceToolStateManager() - script_dir = os.path.dirname(__file__) - file_path_1 = os.path.join(script_dir, 'files/test_not_found.xml') - compliance_tool.check_schema(file_path_1, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.FAILED, manager.steps[0].status) - self.assertEqual(Status.NOT_EXECUTED, manager.steps[1].status) - self.assertEqual(Status.NOT_EXECUTED, manager.steps[2].status) - self.assertIn("No such file or directory", manager.format_step(0, verbose_level=1)) - - manager.steps = [] - file_path_2 = os.path.join(script_dir, 'files/test_empty.xml') - compliance_tool.check_schema(file_path_2, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.SUCCESS, manager.steps[1].status) - self.assertEqual(Status.SUCCESS, manager.steps[2].status) - - manager.steps = [] - file_path_3 = os.path.join(script_dir, 'files/test_demo_full_example.xml') - compliance_tool.check_schema(file_path_3, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.SUCCESS, manager.steps[1].status) - self.assertEqual(Status.SUCCESS, manager.steps[2].status) - - manager.steps = [] - file_path_4 = os.path.join(script_dir, 'files/test_demo_full_example_wrong_attribute.xml') - compliance_tool.check_schema(file_path_4, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.SUCCESS, manager.steps[1].status) - self.assertEqual(Status.SUCCESS, manager.steps[2].status) - def test_check_deserialization(self) -> None: manager = ComplianceToolStateManager() script_dir = os.path.dirname(__file__) diff --git a/sdk/basyx/aas/adapter/aasx.py b/sdk/basyx/aas/adapter/aasx.py index 2c7fe0b4..82fe4b76 100644 --- a/sdk/basyx/aas/adapter/aasx.py +++ b/sdk/basyx/aas/adapter/aasx.py @@ -880,6 +880,7 @@ def rename_file(self, old_name: str, new_name: str) -> str: if new_name == old_name: return new_name file_hash, file_content_type = self._name_map[old_name] + self._store_refcount[file_hash] -= 1 del self._name_map[old_name] return self._assign_unique_name(new_name, file_hash, file_content_type) @@ -889,6 +890,7 @@ def _assign_unique_name(self, name: str, sha: bytes, content_type: str) -> str: while True: if new_name not in self._name_map: self._name_map[new_name] = (sha, content_type) + self._store_refcount[sha] += 1 return new_name elif self._name_map[new_name] == (sha, content_type): return new_name diff --git a/sdk/basyx/aas/adapter/xml/xml_deserialization.py b/sdk/basyx/aas/adapter/xml/xml_deserialization.py index b36dddb9..2330b9af 100644 --- a/sdk/basyx/aas/adapter/xml/xml_deserialization.py +++ b/sdk/basyx/aas/adapter/xml/xml_deserialization.py @@ -1158,7 +1158,7 @@ def construct_data_specification_iec61360(cls, element: etree._Element, if value_list is not None: ds_iec.value_list = value_list value = _get_text_or_none(element.find(NS_AAS + "value")) - if value is not None and value_format is not None: + if value is not None: ds_iec.value = value level_type = element.find(NS_AAS + "levelType") if level_type is not None: diff --git a/sdk/basyx/aas/backend/couchdb.py b/sdk/basyx/aas/backend/couchdb.py index c2871aed..fe02345e 100644 --- a/sdk/basyx/aas/backend/couchdb.py +++ b/sdk/basyx/aas/backend/couchdb.py @@ -160,7 +160,6 @@ def get_identifiable_by_couchdb_id(self, couchdb_id: str) -> model.Identifiable: raise KeyError("No Identifiable with couchdb-id {} found in CouchDB database".format(couchdb_id)) from e raise - # Add CouchDB metadata (for later commits) to object obj = data['data'] if not isinstance(obj, model.Identifiable): raise CouchDBResponseError("The CouchDB document with id {} does not contain an identifiable AAS object." @@ -168,14 +167,10 @@ def get_identifiable_by_couchdb_id(self, couchdb_id: str) -> model.Identifiable: set_couchdb_revision("{}/{}/{}".format(self.url, self.database_name, urllib.parse.quote(couchdb_id, safe='')), data["_rev"]) - # If we still have a local replication of that object (since it is referenced from anywhere else), update that - # replication and return it. with self._object_cache_lock: if obj.id in self._object_cache: - old_obj = self._object_cache[obj.id] - old_obj.update_from(obj) - return old_obj - self._object_cache[obj.id] = obj + return self._object_cache[obj.id] + self._object_cache[obj.id] = obj return obj def get_item(self, identifier: model.Identifier) -> model.Identifiable: @@ -186,6 +181,9 @@ def get_item(self, identifier: model.Identifier) -> model.Identifiable: :raises CouchDBError: If error occur during the request to the CouchDB server (see ``_do_request()`` for details) """ + with self._object_cache_lock: + if identifier in self._object_cache: + return self._object_cache[identifier] try: return self.get_identifiable_by_couchdb_id(self._transform_id(identifier, False)) except KeyError as e: @@ -220,6 +218,37 @@ def add(self, x: model.Identifiable) -> None: with self._object_cache_lock: self._object_cache[x.id] = x + def commit(self, x: model.Identifiable) -> None: + """ + Write the current in-memory state of a stored object back to the CouchDB. + + :param x: The object to persist + :raises KeyError: If the object is not present in the store or no revision is known + :raises CouchDBConflictError: If the object was modified in the database since it was last fetched + :raises CouchDBError: If error occur during the request to the CouchDB server + (see ``_do_request()`` for details) + """ + doc_url = "{}/{}/{}".format(self.url, self.database_name, self._transform_id(x.id)) + rev = get_couchdb_revision(doc_url) + if rev is None: + raise KeyError("No revision found for object with id {} — not fetched from this store".format(x.id)) + data = json.dumps({'data': x}, cls=json_serialization.AASToJsonEncoder) + try: + response = self._do_request( + "{}?rev={}".format(doc_url, rev), + 'PUT', + {'Content-type': 'application/json'}, + data.encode('utf-8')) + set_couchdb_revision(doc_url, response["rev"]) + except CouchDBServerError as e: + if e.code == 404: + raise KeyError("No AAS object with id {} exists in CouchDB database".format(x.id)) from e + elif e.code == 409: + raise CouchDBConflictError( + "Object with id {} has been modified in the database since it was last fetched." + .format(x.id)) from e + raise + def discard(self, x: model.Identifiable, safe_delete=False) -> None: """ Delete an :class:`~basyx.aas.model.base.Identifiable` AAS object from the CouchDB database diff --git a/sdk/basyx/aas/backend/local_file.py b/sdk/basyx/aas/backend/local_file.py index 4008497a..df21ddfe 100644 --- a/sdk/basyx/aas/backend/local_file.py +++ b/sdk/basyx/aas/backend/local_file.py @@ -16,6 +16,7 @@ import json import os import hashlib +import tempfile import threading import warnings import weakref @@ -31,6 +32,13 @@ class LocalFileIdentifiableStore(model.AbstractObjectStore[model.Identifier, mod """ An ObjectStore implementation for :class:`~basyx.aas.model.base.Identifiable` BaSyx Python SDK objects backed by a local file based local backend + + .. warning:: + This backend is intended for development and testing only. It provides no + concurrency control across processes: concurrent writes to the same object + (e.g. under a multi-worker WSGI server) will silently overwrite each other, + with the last writer winning and no error raised. Use a dedicated database + backend for any production deployment. """ def __init__(self, directory_path: str): """ @@ -68,21 +76,16 @@ def get_identifiable_by_hash(self, hash_: str) -> model.Identifiable: :raises KeyError: If the respective file could not be found """ - # Try to get the correct file try: with open("{}/{}.json".format(self.directory_path, hash_), "r") as file: data = json.load(file, cls=json_deserialization.AASFromJsonDecoder) obj = data["data"] except FileNotFoundError as e: raise KeyError("No Identifiable with hash {} found in local file database".format(hash_)) from e - # If we still have a local replication of that object (since it is referenced from anywhere else), update that - # replication and return it. with self._object_cache_lock: if obj.id in self._object_cache: - old_obj = self._object_cache[obj.id] - old_obj.update_from(obj) - return old_obj - self._object_cache[obj.id] = obj + return self._object_cache[obj.id] + self._object_cache[obj.id] = obj return obj def get_item(self, identifier: model.Identifier) -> model.Identifiable: @@ -91,11 +94,33 @@ def get_item(self, identifier: model.Identifier) -> model.Identifiable: :raises KeyError: If the respective file could not be found """ + with self._object_cache_lock: + if identifier in self._object_cache: + return self._object_cache[identifier] try: return self.get_identifiable_by_hash(self._transform_id(identifier)) except KeyError as e: raise KeyError("No Identifiable with id {} found in local file database".format(identifier)) from e + def _write_atomic(self, x: model.Identifiable) -> None: + """ + Serialize x to a temp file in the store directory, then atomically replace the final file. + + Using os.replace() (rename(2) on POSIX) ensures readers always see a complete file — never + a partially-written one from a crash or concurrent access mid-write. + """ + final_path = "{}/{}.json".format(self.directory_path, self._transform_id(x.id)) + tmp_fd, tmp_path = tempfile.mkstemp(dir=self.directory_path, suffix=".tmp") + try: + with os.fdopen(tmp_fd, "w") as tmp_file: + json.dump({"data": x}, tmp_file, cls=json_serialization.AASToJsonEncoder, indent=4) + os.replace(tmp_path, final_path) + # Catch all `Exception`s, as well as `KeyboardInterrupt` and `SystemExit` too, so the temp + # file is never left behind even if the process is being torn down: + except BaseException: + os.unlink(tmp_path) + raise + def add(self, x: model.Identifiable) -> None: """ Add an object to the store @@ -105,10 +130,20 @@ def add(self, x: model.Identifiable) -> None: logger.debug("Adding object %s to Local File Store ...", repr(x)) if os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))): raise KeyError("Identifiable with id {} already exists in local file database".format(x.id)) - with open("{}/{}.json".format(self.directory_path, self._transform_id(x.id)), "w") as file: - json.dump({"data": x}, file, cls=json_serialization.AASToJsonEncoder, indent=4) - with self._object_cache_lock: - self._object_cache[x.id] = x + self._write_atomic(x) + with self._object_cache_lock: + self._object_cache[x.id] = x + + def commit(self, x: model.Identifiable) -> None: + """ + Write the current in-memory state of a stored object back to its file. + + :param x: The object to persist + :raises KeyError: If the object is not present in the store + """ + if not os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))): + raise KeyError("No AAS object with id {} exists in local file database".format(x.id)) + self._write_atomic(x) def discard(self, x: model.Identifiable) -> None: """ @@ -150,7 +185,7 @@ def __len__(self) -> int: :return: The number of objects (determined from the number of documents) """ logger.debug("Fetching number of documents from database ...") - return len(os.listdir(self.directory_path)) + return sum(1 for f in os.listdir(self.directory_path) if f.lower().endswith(".json")) def __iter__(self) -> Iterator[model.Identifiable]: """ @@ -161,7 +196,8 @@ def __iter__(self) -> Iterator[model.Identifiable]: """ logger.debug("Iterating over objects in database ...") for name in os.listdir(self.directory_path): - yield self.get_identifiable_by_hash(name.rstrip(".json")) + if name.lower().endswith(".json"): + yield self.get_identifiable_by_hash(name[:-5]) @staticmethod def _transform_id(identifier: model.Identifier) -> str: diff --git a/sdk/basyx/aas/model/base.py b/sdk/basyx/aas/model/base.py index 6c6eb25e..718c0d63 100644 --- a/sdk/basyx/aas/model/base.py +++ b/sdk/basyx/aas/model/base.py @@ -2077,8 +2077,10 @@ def discard(self, x: _NSO) -> None: def pop(self) -> _NSO: _, value = next(iter(self._backend.values()))[0].popitem() + for key_attr_name, (backend_dict, case_sensitive) in self._backend.items(): + key_attr_value = self._get_attribute(value, key_attr_name, case_sensitive) + backend_dict.pop(key_attr_value, None) self._execute_item_del_hook(value) - value.parent = None return value def clear(self) -> None: diff --git a/sdk/basyx/aas/model/provider.py b/sdk/basyx/aas/model/provider.py index c48342c6..9a91a346 100644 --- a/sdk/basyx/aas/model/provider.py +++ b/sdk/basyx/aas/model/provider.py @@ -59,6 +59,18 @@ class AbstractObjectStore(AbstractObjectProvider[_KEY, _VALUE], MutableSet[_VALU def __init__(self): pass + def commit(self, x: _VALUE) -> None: + """ + Persist an in-memory mutation of a stored object back to the underlying storage. + + Persistent backends (e.g. file-based or database-backed stores) must override this to + write the updated object back to storage. In-memory stores should override this with an + explicit no-op to make the intent clear. + + :param x: The object whose current in-memory state should be persisted + """ + raise NotImplementedError() + def update(self, other: Iterable[_VALUE]) -> None: for x in other: self.add(x) @@ -146,6 +158,9 @@ def add(self, x: _IDENTIFIABLE) -> None: .format(x.id)) self._backend[x.id] = x + def commit(self, x: _IDENTIFIABLE) -> None: + pass + def discard(self, x: _IDENTIFIABLE) -> None: if self._backend.get(x.id) is x: del self._backend[x.id] @@ -223,6 +238,9 @@ def add(self, x: _IDENTIFIABLE) -> None: else: raise KeyError(f"Identifiable object with same id {x.id} is already stored in this store") + def commit(self, x: _IDENTIFIABLE) -> None: + pass + def discard(self, x: _IDENTIFIABLE) -> None: self._backend.discard(x) diff --git a/sdk/test/adapter/aasx/test_aasx.py b/sdk/test/adapter/aasx/test_aasx.py index 271b992c..e4ab1a1d 100644 --- a/sdk/test/adapter/aasx/test_aasx.py +++ b/sdk/test/adapter/aasx/test_aasx.py @@ -89,6 +89,24 @@ def test_supplementary_file_container(self) -> None: with self.assertRaises(KeyError): container.write_file(duplicate_file, file_content) + def test_supplementary_file_container_refcount(self) -> None: + container = aasx.DictSupplementaryFileContainer() + data = b"test content" + name1 = container.add_file("/file1.bin", io.BytesIO(data), "application/octet-stream") + name2 = container.add_file("/file2.bin", io.BytesIO(data), "application/octet-stream") + content_hash = container.get_sha256(name1) + + # Both names point to same content — backing store must be present + self.assertIn(content_hash, container._store) + + # Deleting one reference must NOT free the backing store + container.delete_file(name1) + self.assertIn(content_hash, container._store) + + # Deleting the last reference must free the backing store + container.delete_file(name2) + self.assertNotIn(content_hash, container._store) + class AASXWriterTest(unittest.TestCase): def test_write_missing_aas_objects(self): diff --git a/sdk/test/adapter/xml/test_xml_deserialization.py b/sdk/test/adapter/xml/test_xml_deserialization.py index 2857a9dc..14a5041b 100644 --- a/sdk/test/adapter/xml/test_xml_deserialization.py +++ b/sdk/test/adapter/xml/test_xml_deserialization.py @@ -428,6 +428,50 @@ def test_stripped_asset_administration_shell(self) -> None: self.assertEqual(len(aas.submodel), 0) +class XmlDeserializationDataSpecTest(unittest.TestCase): + def test_data_spec_iec61360_value_without_value_format(self) -> None: + xml = _xml_wrap(f""" + + + http://example.org/test_cd + + + + ExternalReference + + + GlobalReference + https://admin-shell.io/DataSpecificationTemplates/DataSpecificationIec61360/3/0 + + + + + + + + en + Test + + + test_value + + + + + + + """) + object_store = read_aas_xml_file(io.StringIO(xml), failsafe=False) + cd = object_store.get_item("http://example.org/test_cd") + self.assertIsInstance(cd, model.ConceptDescription) + assert isinstance(cd, model.ConceptDescription) + ds_content = list(cd.embedded_data_specifications)[0].data_specification_content + self.assertIsInstance(ds_content, model.DataSpecificationIEC61360) + assert isinstance(ds_content, model.DataSpecificationIEC61360) + self.assertEqual("test_value", ds_content.value) + self.assertIsNone(ds_content.value_format) + + class XmlDeserializationDerivingTest(unittest.TestCase): def test_submodel_constructor_overriding(self) -> None: class EnhancedSubmodel(model.Submodel): diff --git a/sdk/test/backend/test_local_file.py b/sdk/test/backend/test_local_file.py index adcbfcc7..71447f61 100644 --- a/sdk/test/backend/test_local_file.py +++ b/sdk/test/backend/test_local_file.py @@ -4,6 +4,7 @@ # the LICENSE file of this project. # # SPDX-License-Identifier: MIT +import gc import os.path import shutil @@ -107,6 +108,59 @@ def test_key_errors(self) -> None: self.assertEqual("'No AAS object with id https://example.org/Test_Submodel exists in " "local file database'", str(cm.exception)) + def test_add_and_len_consistent(self) -> None: + # Each add() must increment len() by exactly 1 + example_data = list(create_full_example()) + for i, item in enumerate(example_data): + self.identifiable_store.add(item) + self.assertEqual(i + 1, len(self.identifiable_store)) + + # Stray non-json file must not be counted + stray = os.path.join(store_path, ".DS_Store") + with open(stray, "w") as f: + f.write("stray") + self.assertEqual(len(example_data), len(self.identifiable_store)) + os.remove(stray) + + def test_iter_ignores_non_json_files(self) -> None: + example_data = create_full_example() + for item in example_data: + self.identifiable_store.add(item) + + # Stray files must not crash the iterator or be yielded + stray = os.path.join(store_path, ".DS_Store") + with open(stray, "w") as f: + f.write("stray") + items = list(self.identifiable_store) + self.assertEqual(5, len(items)) + os.remove(stray) + + def test_mutation_persistence(self) -> None: + submodel = model.Submodel( + id_='https://example.org/MutationTest', + submodel_element={ + model.Property(id_short='Prop', value_type=model.datatypes.String, value='before') + } + ) + self.identifiable_store.add(submodel) + + retrieved = self.identifiable_store.get_item('https://example.org/MutationTest') + assert isinstance(retrieved, model.Submodel) + prop = retrieved.get_referable(['Prop']) + assert isinstance(prop, model.Property) + prop.update_from(model.Property(id_short='Prop', value_type=model.datatypes.String, value='after')) + self.identifiable_store.commit(retrieved) + + # Drop all strong references to evict the WeakValueDictionary cache + del submodel, retrieved, prop + gc.collect() + + fresh = self.identifiable_store.get_item('https://example.org/MutationTest') + assert isinstance(fresh, model.Submodel) + fresh_prop = fresh.get_referable(['Prop']) + assert isinstance(fresh_prop, model.Property) + self.assertEqual('after', fresh_prop.value) + def test_reload_discard(self) -> None: # Load example submodel example_submodel = create_example_submodel() diff --git a/sdk/test/model/test_base.py b/sdk/test/model/test_base.py index c5b0429d..3a74a774 100644 --- a/sdk/test/model/test_base.py +++ b/sdk/test/model/test_base.py @@ -338,6 +338,18 @@ def setUp(self): self.namespace = self._namespace_class() self.namespace3 = self._namespace_class_qualifier() + def test_namespaceset_pop_removes_from_all_backends(self) -> None: + # set1 has two backends: id_short and semantic_id + self.namespace.set1.add(self.prop1) + popped = self.namespace.set1.pop() + self.assertIs(self.prop1, popped) + self.assertEqual(0, len(self.namespace.set1)) + # After pop, adding a new item with the same semantic_id must NOT raise AASConstraintViolation — + # it would if the popped item's semantic_id entry were still in the backend + new_prop = model.Property("NewProp", model.datatypes.Int, semantic_id=self.propSemanticID) + self.namespace.set1.add(new_prop) + self.assertEqual(1, len(self.namespace.set1)) + def test_NamespaceSet(self) -> None: self.namespace.set1.add(self.prop1) self.assertEqual(1, len(self.namespace.set1)) diff --git a/sdk/test/model/test_provider.py b/sdk/test/model/test_provider.py index 10947c16..2cefda23 100644 --- a/sdk/test/model/test_provider.py +++ b/sdk/test/model/test_provider.py @@ -55,6 +55,24 @@ def test_store_update(self) -> None: self.assertIsInstance(identifiable_store1, model.DictIdentifiableStore) self.assertIn(self.aas2, identifiable_store1) + def test_store_sync(self) -> None: + aas_identifiable_store: model.DictIdentifiableStore[model.Identifiable] = model.DictIdentifiableStore() + + self.assertEqual(aas_identifiable_store.sync([self.aas1, self.aas2], overwrite=False), (2, 0, 0)) + self.assertIn(self.aas1, aas_identifiable_store) + self.assertIn(self.aas2, aas_identifiable_store) + + self.assertEqual(aas_identifiable_store.sync([self.aas1], overwrite=False), (0, 0, 1)) + + self.assertEqual(aas_identifiable_store.sync([self.aas1], overwrite=True), (0, 1, 0)) + self.assertIn(self.aas1, aas_identifiable_store) + + self.assertEqual(aas_identifiable_store.sync([self.aas1, self.submodel1], overwrite=True), (1, 1, 0)) + + self.assertEqual(aas_identifiable_store.sync([self.aas1, self.submodel2], overwrite=False), (1, 0, 1)) + + self.assertEqual(aas_identifiable_store.sync([], overwrite=False), (0, 0, 0)) + def test_provider_multiplexer(self) -> None: aas_identifiable_store: model.DictIdentifiableStore[model.Identifiable] = ( model.DictIdentifiableStore() diff --git a/server/app/adapter/jsonization.py b/server/app/adapter/jsonization.py index a8ee3471..897590c7 100644 --- a/server/app/adapter/jsonization.py +++ b/server/app/adapter/jsonization.py @@ -1,10 +1,10 @@ import logging -from typing import Callable, Dict, Optional, Set, Type +from typing import Callable, Dict, Type from basyx.aas import model -from basyx.aas.adapter._generic import ASSET_KIND, ASSET_KIND_INVERSE, JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES, PathOrIO +from basyx.aas.adapter._generic import ASSET_KIND, ASSET_KIND_INVERSE, JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES from basyx.aas.adapter.json import AASToJsonEncoder -from basyx.aas.adapter.json.json_deserialization import AASFromJsonDecoder, _get_ts, read_aas_json_file_into +from basyx.aas.adapter.json.json_deserialization import AASFromJsonDecoder, _get_ts import app.model as server_model @@ -207,27 +207,6 @@ class ServerStrictStrippedAASFromJsonDecoder(ServerStrictAASFromJsonDecoder, Ser pass -def read_server_aas_json_file_into( - object_store: model.AbstractObjectStore, - file: PathOrIO, - replace_existing: bool = False, - ignore_existing: bool = False, - failsafe: bool = True, - stripped: bool = False, - decoder: Optional[Type[AASFromJsonDecoder]] = None, -) -> Set[model.Identifier]: - return read_aas_json_file_into( - object_store=object_store, - file=file, - replace_existing=replace_existing, - ignore_existing=ignore_existing, - failsafe=failsafe, - stripped=stripped, - decoder=decoder, - keys_to_types=JSON_SERVER_AAS_TOP_LEVEL_KEYS_TO_TYPES, - ) - - class ServerAASToJsonEncoder(AASToJsonEncoder): @classmethod diff --git a/server/app/backend/local_file.py b/server/app/backend/local_file.py index e55c08e6..e71a0e98 100644 --- a/server/app/backend/local_file.py +++ b/server/app/backend/local_file.py @@ -67,20 +67,15 @@ def get_descriptor_by_hash(self, hash_: str) -> _DESCRIPTOR_TYPE: :raises KeyError: If the respective file could not be found """ - # Try to get the correct file try: with open("{}/{}.json".format(self.directory_path, hash_), "r") as file: obj = json.load(file, cls=jsonization.ServerAASFromJsonDecoder) except FileNotFoundError as e: raise KeyError("No Descriptor with hash {} found in local file database".format(hash_)) from e - # If we still have a local replication of that object (since it is referenced from anywhere else), update that - # replication and return it. with self._object_cache_lock: if obj.id in self._object_cache: - old_obj = self._object_cache[obj.id] - old_obj.update_from(obj) - return old_obj - self._object_cache[obj.id] = obj + return self._object_cache[obj.id] + self._object_cache[obj.id] = obj return obj def get_item(self, identifier: model.Identifier) -> _DESCRIPTOR_TYPE: @@ -89,6 +84,9 @@ def get_item(self, identifier: model.Identifier) -> _DESCRIPTOR_TYPE: :raises KeyError: If the respective file could not be found """ + with self._object_cache_lock: + if identifier in self._object_cache: + return self._object_cache[identifier] try: return self.get_descriptor_by_hash(self._transform_id(identifier)) except KeyError as e: @@ -113,6 +111,20 @@ def add(self, x: _DESCRIPTOR_TYPE) -> None: with self._object_cache_lock: self._object_cache[x.id] = x + def commit(self, x: _DESCRIPTOR_TYPE) -> None: + """ + Write the current in-memory state of a stored descriptor back to its file. + + :param x: The descriptor to persist + :raises KeyError: If the descriptor is not present in the store + """ + if not os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))): + raise KeyError("No AAS Descriptor object with id {} exists in local file database".format(x.id)) + with open("{}/{}.json".format(self.directory_path, self._transform_id(x.id)), "w") as file: + serialized = json.loads(json.dumps(x, cls=jsonization.ServerAASToJsonEncoder)) + serialized["modelType"] = DESCRIPTOR_TYPE_TO_STRING[type(x)] + json.dump(serialized, file, indent=4) + def discard(self, x: _DESCRIPTOR_TYPE) -> None: """ Delete an :class:`~app.model.descriptor.Descriptor` AAS object from the local file store diff --git a/server/app/interfaces/base.py b/server/app/interfaces/base.py index d3231237..865b599b 100644 --- a/server/app/interfaces/base.py +++ b/server/app/interfaces/base.py @@ -10,7 +10,7 @@ import io import itertools import json -from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Type, TypeVar, Union +from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Protocol, Tuple, Type, TypeVar, Union, runtime_checkable import werkzeug.exceptions import werkzeug.routing @@ -295,6 +295,24 @@ def http_exception_to_response( return response_type(result, status=exception.code, headers=headers) +@runtime_checkable +class QueryableObjectStore(Protocol): + """Structural protocol for object stores that support AASQL querying. + + Implement ``query(aasql_body, return_var)`` to advertise query support. + No explicit inheritance required — duck-typing via ``isinstance`` works at runtime. + """ + + def query(self, aasql_body: str, return_var: str) -> List[dict]: + """Execute an AASQL query and return matching serialized AAS objects. + + :param aasql_body: raw AASQL JSON string + :param return_var: Cypher return variable name (``"sm"`` or ``"aas"``) + :return: list of serialized AAS/Submodel dicts + """ + ... + + class ObjectStoreWSGIApp(BaseWSGIApp): object_store: AbstractObjectStore diff --git a/server/app/interfaces/registry.py b/server/app/interfaces/registry.py index 37ab9555..274602ff 100644 --- a/server/app/interfaces/registry.py +++ b/server/app/interfaces/registry.py @@ -180,7 +180,7 @@ def post_aas_descriptor( self.object_store.add(descriptor) except KeyError as e: raise Conflict(f"AssetAdministrationShellDescriptor with Identifier {descriptor.id} already exists!") from e - descriptor.commit() + self.object_store.commit(descriptor) created_resource_url = map_adapter.build( self.get_aas_descriptor_by_id, {"aas_id": descriptor.id}, force_external=True ) @@ -202,12 +202,12 @@ def put_aas_descriptor_by_id( request, server_model.AssetAdministrationShellDescriptor, is_stripped_request(request) ) ) - descriptor.commit() + self.object_store.commit(descriptor) return response_t() except NotFound: descriptor = HTTPApiDecoder.request_body(request, server_model.AssetAdministrationShellDescriptor, False) self.object_store.add(descriptor) - descriptor.commit() + self.object_store.commit(descriptor) created_resource_url = map_adapter.build( self.get_aas_descriptor_by_id, {"aas_id": descriptor.id}, force_external=True ) @@ -247,7 +247,7 @@ def post_submodel_descriptor_through_superpath( if any(sd.id == submodel_descriptor.id for sd in aas_descriptor.submodel_descriptors): raise Conflict(f"Submodel Descriptor with Identifier {submodel_descriptor.id} already exists!") aas_descriptor.submodel_descriptors.append(submodel_descriptor) - aas_descriptor.commit() + self.object_store.commit(aas_descriptor) created_resource_url = map_adapter.build( self.get_submodel_descriptor_by_id_through_superpath, {"aas_id": aas_descriptor.id, "submodel_id": submodel_descriptor.id}, @@ -269,14 +269,14 @@ def put_submodel_descriptor_by_id_through_superpath( submodel_descriptor.update_from( HTTPApiDecoder.request_body(request, server_model.SubmodelDescriptor, is_stripped_request(request)) ) - aas_descriptor.commit() + self.object_store.commit(aas_descriptor) return response_t() except NotFound: submodel_descriptor = HTTPApiDecoder.request_body( request, server_model.SubmodelDescriptor, is_stripped_request(request) ) aas_descriptor.submodel_descriptors.append(submodel_descriptor) - aas_descriptor.commit() + self.object_store.commit(aas_descriptor) created_resource_url = map_adapter.build( self.get_submodel_descriptor_by_id_through_superpath, {"aas_id": aas_descriptor.id, "submodel_id": submodel_descriptor.id}, @@ -293,7 +293,7 @@ def delete_submodel_descriptor_by_id_through_superpath( if submodel_descriptor is None: raise NotFound(f"Submodel Descriptor with Identifier {submodel_id} not found in AssetAdministrationShell!") aas_descriptor.submodel_descriptors.remove(submodel_descriptor) - aas_descriptor.commit() + self.object_store.commit(aas_descriptor) return response_t() # ------ Submodel REGISTRY ROUTES ------- @@ -321,7 +321,7 @@ def post_submodel_descriptor( self.object_store.add(submodel_descriptor) except KeyError as e: raise Conflict(f"Submodel Descriptor with Identifier {submodel_descriptor.id} already exists!") from e - submodel_descriptor.commit() + self.object_store.commit(submodel_descriptor) created_resource_url = map_adapter.build( self.get_submodel_descriptor_by_id, {"submodel_id": submodel_descriptor.id}, force_external=True ) @@ -335,14 +335,14 @@ def put_submodel_descriptor_by_id( submodel_descriptor.update_from( HTTPApiDecoder.request_body(request, server_model.SubmodelDescriptor, is_stripped_request(request)) ) - submodel_descriptor.commit() + self.object_store.commit(submodel_descriptor) return response_t() except NotFound: submodel_descriptor = HTTPApiDecoder.request_body( request, server_model.SubmodelDescriptor, is_stripped_request(request) ) self.object_store.add(submodel_descriptor) - submodel_descriptor.commit() + self.object_store.commit(submodel_descriptor) created_resource_url = map_adapter.build( self.get_submodel_descriptor_by_id, {"submodel_id": submodel_descriptor.id}, force_external=True ) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 0e75eedd..59622920 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -24,7 +24,7 @@ from app.interfaces.base import PagingMetadata from app.util.converters import IdentifierToBase64URLConverter, IdShortPathConverter, base64url_decode -from .base import ObjectStoreWSGIApp, APIResponse, is_stripped_request, HTTPApiDecoder, T +from .base import ObjectStoreWSGIApp, APIResponse, is_stripped_request, HTTPApiDecoder, QueryableObjectStore, T from app.model import ServiceSpecificationProfileEnum, ServiceDescription SUPPORTED_PROFILES: ServiceDescription = ServiceDescription([ @@ -32,6 +32,8 @@ ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_FULL, ServiceSpecificationProfileEnum.AAS_REPOSITORY_READ, ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_READ, + # ServiceSpecificationProfileEnum.AAS_REPOSITORY_QUERY, + # ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_QUERY, ]) @@ -49,6 +51,8 @@ def __init__( Submount( base_path, [ + Rule("/query/shells", methods=["POST"], endpoint=self.query_shells), + Rule("/query/submodels", methods=["POST"], endpoint=self.query_submodels), Rule("/serialization", methods=["GET"], endpoint=self.not_implemented), Rule("/description", methods=["GET"], endpoint=self.get_description), Rule("/shells", methods=["GET"], endpoint=self.get_aas_all), @@ -470,10 +474,7 @@ def _get_shells( for specific_asset_id in specific_asset_ids ) ) - and ( - len(global_asset_ids) <= 1 - and (not global_asset_ids or shell.asset_information.global_asset_id in global_asset_ids) - ) + and (not global_asset_ids or shell.asset_information.global_asset_id in global_asset_ids) ), aas, ) @@ -517,12 +518,42 @@ def _get_submodel_submodel_elements_id_short_path(self, url_args: Dict) -> model def _get_concept_description(self, url_args): return self._get_obj_ts(url_args["concept_id"], model.ConceptDescription) + def query_submodels(self, request: Request, url_args: Dict, **_kwargs) -> Response: + if not isinstance(self.object_store, QueryableObjectStore): + raise werkzeug.exceptions.NotImplemented("The current store does not support AASQL queries") + try: + results = self.object_store.query(request.get_data(as_text=True), "sm") + except (json.JSONDecodeError, ValueError) as e: + raise BadRequest(f"Invalid AASQL query: {e}") from e + return Response( + json.dumps({"paging_metadata": {"resultType": "Submodel"}, "result": results}), + content_type="application/json", + ) + + def query_shells(self, request: Request, url_args: Dict, **_kwargs) -> Response: + if not isinstance(self.object_store, QueryableObjectStore): + raise werkzeug.exceptions.NotImplemented("The current store does not support AASQL queries") + try: + results = self.object_store.query(request.get_data(as_text=True), "aas") + except (json.JSONDecodeError, ValueError) as e: + raise BadRequest(f"Invalid AASQL query: {e}") from e + return Response( + json.dumps({"paging_metadata": {"resultType": "AssetAdministrationShell"}, "result": results}), + content_type="application/json", + ) + # ------ all not implemented ROUTES ------- def not_implemented(self, request: Request, url_args: Dict, **_kwargs) -> Response: raise werkzeug.exceptions.NotImplemented("This route is not implemented!") def get_description(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: - return response_t(SUPPORTED_PROFILES.to_dict()) + profiles = SUPPORTED_PROFILES.to_dict() + if isinstance(self.object_store, QueryableObjectStore): + profiles["profiles"].extend([ + ServiceSpecificationProfileEnum.AAS_REPOSITORY_QUERY.value, + ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_QUERY.value, + ]) + return response_t(profiles) # ------ AAS REPO ROUTES ------- def get_aas_all(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: @@ -562,6 +593,7 @@ def put_aas(self, request: Request, url_args: Dict, response_t: Type[APIResponse aas.update_from( HTTPApiDecoder.request_body(request, model.AssetAdministrationShell, is_stripped_request(request)) ) + self.object_store.commit(aas) return response_t() def delete_aas(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: @@ -580,6 +612,7 @@ def put_aas_asset_information( ) -> Response: aas = self._get_shell(url_args) aas.asset_information = HTTPApiDecoder.request_body(request, model.AssetInformation, False) + self.object_store.commit(aas) return response_t() def get_aas_submodel_refs( @@ -598,6 +631,7 @@ def post_aas_submodel_refs(self, request: Request, url_args: Dict, response_t: T if sm_ref in aas.submodel: raise Conflict(f"{sm_ref!r} already exists!") aas.submodel.add(sm_ref) + self.object_store.commit(aas) created_resource_url = map_adapter.build(self.delete_aas_submodel_refs_specific, { "aas_id": aas.id, "submodel_id": sm_ref.key[0].value @@ -609,6 +643,7 @@ def delete_aas_submodel_refs_specific( ) -> Response: aas = self._get_shell(url_args) aas.submodel.remove(self._get_submodel_reference(aas, url_args["submodel_id"])) + self.object_store.commit(aas) return response_t() def put_aas_submodel_refs_submodel( @@ -622,9 +657,11 @@ def put_aas_submodel_refs_submodel( id_changed: bool = submodel.id != new_submodel.id # TODO: https://github.com/eclipse-basyx/basyx-python-sdk/issues/216 submodel.update_from(new_submodel) + self.object_store.commit(submodel) if id_changed: aas.submodel.remove(sm_ref) aas.submodel.add(model.ModelReference.from_referable(submodel)) + self.object_store.commit(aas) return response_t() def delete_aas_submodel_refs_submodel( @@ -635,6 +672,7 @@ def delete_aas_submodel_refs_submodel( submodel = self._resolve_reference(sm_ref) self.object_store.remove(submodel) aas.submodel.remove(sm_ref) + self.object_store.commit(aas) return response_t() def aas_submodel_refs_redirect( @@ -711,6 +749,7 @@ def get_submodels_reference( def put_submodel(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: submodel = self._get_submodel(url_args) submodel.update_from(HTTPApiDecoder.request_body(request, model.Submodel, is_stripped_request(request))) + self.object_store.commit(submodel) return response_t() def get_submodel_submodel_elements( @@ -778,6 +817,7 @@ def post_submodel_submodel_elements_id_short_path( raise Conflict( f"SubmodelElement with idShort {new_submodel_element.id_short} already exists " f"within {parent}!" ) + self.object_store.commit(self._get_submodel(url_args)) submodel = self._get_submodel(url_args) id_short_path = url_args.get("id_shorts", []) created_resource_url = map_adapter.build( @@ -790,21 +830,25 @@ def post_submodel_submodel_elements_id_short_path( def put_submodel_submodel_elements_id_short_path( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: - submodel_element = self._get_submodel_submodel_elements_id_short_path(url_args) + submodel = self._get_submodel(url_args) + submodel_element = self._get_nested_submodel_element(submodel, url_args["id_shorts"]) # TODO: remove the following type: ignore comment when mypy supports abstract types for Type[T] # see https://github.com/python/mypy/issues/5374 new_submodel_element = HTTPApiDecoder.request_body( request, model.SubmodelElement, is_stripped_request(request) # type: ignore[type-abstract] ) submodel_element.update_from(new_submodel_element) + self.object_store.commit(submodel) return response_t() def delete_submodel_submodel_elements_id_short_path( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: - sm_or_se = self._get_submodel_or_nested_submodel_element(url_args) + submodel = self._get_submodel(url_args) + sm_or_se = self._get_nested_submodel_element(submodel, url_args["id_shorts"]) parent: model.UniqueIdShortNamespace = self._expect_namespace(sm_or_se.parent, sm_or_se.id_short) self._namespace_submodel_element_op(parent, parent.remove_referable, sm_or_se.id_short) + self.object_store.commit(submodel) return response_t() def get_submodel_submodel_element_attachment(self, request: Request, url_args: Dict, **_kwargs) -> Response: @@ -857,6 +901,7 @@ def put_submodel_submodel_element_attachment( ) submodel_element.value = self.file_store.add_file(filename, file_storage.stream, submodel_element.content_type) + self.object_store.commit(self._get_submodel(url_args)) return response_t() def delete_submodel_submodel_element_attachment( @@ -879,6 +924,7 @@ def delete_submodel_submodel_element_attachment( pass submodel_element.value = None + self.object_store.commit(self._get_submodel(url_args)) return response_t() def get_submodel_submodel_element_qualifiers( @@ -898,6 +944,7 @@ def post_submodel_submodel_element_qualifiers( if sm_or_se.qualifier.contains_id("type", qualifier.type): raise Conflict(f"Qualifier with type {qualifier.type} already exists!") sm_or_se.qualifier.add(qualifier) + self.object_store.commit(self._get_submodel(url_args)) created_resource_url = map_adapter.build( self.get_submodel_submodel_element_qualifiers, { @@ -921,6 +968,7 @@ def put_submodel_submodel_element_qualifiers( raise Conflict(f"A qualifier of type {new_qualifier.type!r} already exists for {sm_or_se!r}") sm_or_se.remove_qualifier_by_type(qualifier.type) sm_or_se.qualifier.add(new_qualifier) + self.object_store.commit(self._get_submodel(url_args)) if qualifier_type_changed: created_resource_url = map_adapter.build( self.get_submodel_submodel_element_qualifiers, @@ -940,6 +988,7 @@ def delete_submodel_submodel_element_qualifiers( sm_or_se = self._get_submodel_or_nested_submodel_element(url_args) qualifier_type = url_args["qualifier_type"] self._qualifiable_qualifier_op(sm_or_se, sm_or_se.remove_qualifier_by_type, qualifier_type) + self.object_store.commit(self._get_submodel(url_args)) return response_t() # --------- CONCEPT DESCRIPTION ROUTES --------- @@ -979,6 +1028,7 @@ def put_concept_description( concept_description.update_from( HTTPApiDecoder.request_body(request, model.ConceptDescription, is_stripped_request(request)) ) + self.object_store.commit(concept_description) return response_t() def delete_concept_description( diff --git a/server/app/model/provider.py b/server/app/model/provider.py index 97067e7d..8bef91e5 100644 --- a/server/app/model/provider.py +++ b/server/app/model/provider.py @@ -1,10 +1,11 @@ +import json from pathlib import Path from typing import IO, Dict, Iterable, Iterator, Union from basyx.aas import model from basyx.aas.model import provider as sdk_provider -import app.adapter as adapter +from app import adapter from app.model import descriptor PathOrIO = Union[Path, IO] @@ -51,29 +52,33 @@ def __iter__(self) -> Iterator[_DESCRIPTOR_TYPE]: return iter(self._backend.values()) +_DESCRIPTOR_KEY_TO_CLS = ( + ("assetAdministrationShellDescriptors", descriptor.AssetAdministrationShellDescriptor), + ("submodelDescriptors", descriptor.SubmodelDescriptor), +) + + def load_directory(directory: Union[Path, str]) -> DictDescriptorStore: """ - Create a new :class:`~basyx.aas.model.provider.DictIdentifiableStore` and use it to load Asset Administration Shell - and Submodel files in ``AASX``, ``JSON`` and ``XML`` format from a given directory into memory. Additionally, load - all embedded supplementary files into a new :class:`~basyx.aas.adapter.aasx.DictSupplementaryFileContainer`. - - :param directory: :class:`~pathlib.Path` or ``str`` pointing to the directory containing all Asset Administration - Shell and Submodel files to load - :return: Tuple consisting of a :class:`~basyx.aas.model.provider.DictIdentifiableStore` and a - :class:`~basyx.aas.adapter.aasx.DictSupplementaryFileContainer` containing all loaded data - """ - - dict_descriptor_store: DictDescriptorStore = DictDescriptorStore() + Load AAS/Submodel descriptor JSON files from a directory into a :class:`DictDescriptorStore`. + :param directory: Path to the directory containing JSON descriptor files + :return: Populated :class:`DictDescriptorStore` + """ + store = DictDescriptorStore() directory = Path(directory) for file in directory.iterdir(): - if not file.is_file(): + if not file.is_file() or file.suffix.lower() != ".json": continue - - suffix = file.suffix.lower() - if suffix == ".json": - with open(file) as f: - adapter.read_server_aas_json_file_into(dict_descriptor_store, f) - - return dict_descriptor_store + with open(file) as f: + data = json.load(f, cls=adapter.ServerAASFromJsonDecoder) + for key, cls in _DESCRIPTOR_KEY_TO_CLS: + for item in data.get(key, []): + if isinstance(item, cls): + try: + store.add(item) + except KeyError: + pass + + return store diff --git a/server/app/model/service_specification.py b/server/app/model/service_specification.py index 00b4a5da..5181901a 100644 --- a/server/app/model/service_specification.py +++ b/server/app/model/service_specification.py @@ -5,8 +5,11 @@ class ServiceSpecificationProfileEnum(str, enum.Enum): """ Enumeration of all standardized Service Specification Profiles - from the AAS Part 2 API Specification (IDTA-01002-3-1). + from the AAS Part 2 API Specification (IDTA-01002-3-1-2). Each profile is uniquely identified by its semantic URI. + + Reference: https://industrialdigitaltwin.io/aas-specifications/IDTA-01002/v3.1.2/ + http-rest-api/service-specifications-and-profiles.html """ # --- Asset Administration Shell (AAS) --- @@ -15,8 +18,8 @@ class ServiceSpecificationProfileEnum(str, enum.Enum): # --- Submodel --- SUBMODEL_FULL = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-001" - SUBMODEL_VALUE = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-002" - SUBMODEL_READ = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-003" + SUBMODEL_READ = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-002" + SUBMODEL_VALUE = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-003" # --- AASX File Server --- AASX_FILESERVER_FULL = "https://admin-shell.io/aas/API/3/1/AasxFileServerServiceSpecification/SSP-001" @@ -28,32 +31,40 @@ class ServiceSpecificationProfileEnum(str, enum.Enum): "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-002" AAS_REGISTRY_BULK = \ "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-003" + AAS_REGISTRY_QUERY = \ + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-004" + AAS_REGISTRY_MINIMAL_READ = \ + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-005" # --- Submodel Registry --- SUBMODEL_REGISTRY_FULL = "https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-001" SUBMODEL_REGISTRY_READ = "https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-002" SUBMODEL_REGISTRY_BULK = "https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-003" + SUBMODEL_REGISTRY_QUERY = "https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-004" # --- AAS Repository --- AAS_REPOSITORY_FULL = \ "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRepositoryServiceSpecification/SSP-001" AAS_REPOSITORY_READ = \ "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRepositoryServiceSpecification/SSP-002" - AAS_REPOSITORY_BULK = \ + AAS_REPOSITORY_QUERY = \ "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRepositoryServiceSpecification/SSP-003" # --- Submodel Repository --- SUBMODEL_REPOSITORY_FULL = "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-001" SUBMODEL_REPOSITORY_READ = "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-002" - SUBMODEL_REPOSITORY_BULK = "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-003" + SUBMODEL_REPOSITORY_TEMPLATE = \ + "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-003" + SUBMODEL_REPOSITORY_TEMPLATE_READ = \ + "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-004" + SUBMODEL_REPOSITORY_QUERY = \ + "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-005" # --- Concept Description Repository --- CONCEPT_DESCRIPTION_REPOSITORY_FULL = \ "https://admin-shell.io/aas/API/3/1/ConceptDescriptionRepositoryServiceSpecification/SSP-001" - CONCEPT_DESCRIPTION_REPOSITORY_READ = \ + CONCEPT_DESCRIPTION_REPOSITORY_QUERY = \ "https://admin-shell.io/aas/API/3/1/ConceptDescriptionRepositoryServiceSpecification/SSP-002" - CONCEPT_DESCRIPTION_REPOSITORY_BULK = \ - "https://admin-shell.io/aas/API/3/1/ConceptDescriptionRepositoryServiceSpecification/SSP-003" # --- Discovery --- DISCOVERY_FULL = "https://admin-shell.io/aas/API/3/1/DiscoveryServiceSpecification/SSP-001" diff --git a/server/test/interfaces/test_shells_asset_ids.py b/server/test/interfaces/test_shells_asset_ids.py index 8b48d6de..da103807 100644 --- a/server/test/interfaces/test_shells_asset_ids.py +++ b/server/test/interfaces/test_shells_asset_ids.py @@ -16,6 +16,8 @@ from app.interfaces.repository import WSGIApp +BASE_PATH = "/api/v3.1" + def _encode_asset_id(name: str, value: str) -> str: payload = json.dumps({"name": name, "value": value}) @@ -24,10 +26,24 @@ def _encode_asset_id(name: str, value: str) -> str: class ShellsAssetIdsTest(unittest.TestCase): def setUp(self) -> None: - app = WSGIApp(create_full_example(), DictSupplementaryFileContainer()) + self.example_data = create_full_example() + app = WSGIApp(self.example_data, DictSupplementaryFileContainer()) self.client = Client(app) + def test_multiple_global_asset_ids_returns_matching_results(self) -> None: + aas_list = [obj for obj in self.example_data if isinstance(obj, model.AssetAdministrationShell)] + known_id = aas_list[0].asset_information.global_asset_id + assert known_id is not None + unknown_id = "http://example.org/nonexistent_asset" + id1 = _encode_asset_id("globalAssetId", known_id) + id2 = _encode_asset_id("globalAssetId", unknown_id) + response = self.client.get(f"{BASE_PATH}/shells?assetIds={id1}&assetIds={id2}") + self.assertEqual(200, response.status_code) + result = json.loads(response.data) + returned_ids = [r["id"] for r in result] + self.assertIn(aas_list[0].id, returned_ids) + def test_malformed_asset_id_missing_field_returns_400(self) -> None: bad_payload = base64.urlsafe_b64encode(b'{"name": "globalAssetId"}').decode() - response = self.client.get(f"/api/v3.1/shells?assetIds={bad_payload}") + response = self.client.get(f"{BASE_PATH}/shells?assetIds={bad_payload}") self.assertEqual(400, response.status_code)