From 2e9e5d6ef03fbe3ad38934393db096a5f0bf2416 Mon Sep 17 00:00:00 2001
From: Michael Hoennig <michael.hoennig@hostsharing.net>
Date: Mon, 6 May 2024 10:50:59 +0200
Subject: [PATCH] hosting-asset-validation-for-cloud-server-to-webspace (#54)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/54
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
---
 .../asset/HsHostingAssetController.java       |  12 +-
 .../asset/HsHostingAssetPropsController.java  |  39 ++++
 .../HsHostingAssetPropertyValidator.java      | 172 ++++++++++++++++++
 .../validator/HsHostingAssetValidator.java    |  99 ++++++++++
 .../hs/hosting/asset/validator/lombok.config  |   3 +
 .../hs-hosting/hs-hosting-asset-schemas.yaml  |  93 ++++++++--
 .../hs-hosting-asset-types-props.yaml         |  26 +++
 .../hs-hosting/hs-hosting-asset-types.yaml    |  19 ++
 .../hs-hosting/hs-hosting-assets.yaml         |   4 +-
 .../api-definition/hs-hosting/hs-hosting.yaml |  10 +-
 .../hsadminng/arch/ArchitectureTest.java      |   1 +
 ...sHostingAssetControllerAcceptanceTest.java |  37 +++-
 ...ingAssetPropsControllerAcceptanceTest.java | 156 ++++++++++++++++
 .../HsHostingAssetValidatorUnitTest.java      |  97 ++++++++++
 14 files changed, 750 insertions(+), 18 deletions(-)
 create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java
 create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validator/HsHostingAssetPropertyValidator.java
 create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validator/HsHostingAssetValidator.java
 create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validator/lombok.config
 create mode 100644 src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types-props.yaml
 create mode 100644 src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types.yaml
 create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsControllerAcceptanceTest.java
 create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetValidatorUnitTest.java

diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java
index 62a62b34..384fc2e3 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java
@@ -1,5 +1,6 @@
 package net.hostsharing.hsadminng.hs.hosting.asset;
 
+import net.hostsharing.hsadminng.hs.hosting.asset.validator.HsHostingAssetValidator;
 import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetsApi;
 
 import net.hostsharing.hsadminng.context.Context;
@@ -15,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
 
+import jakarta.validation.ValidationException;
 import java.util.List;
 import java.util.UUID;
 import java.util.function.BiConsumer;
@@ -60,7 +62,7 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
 
         final var entityToSave = mapper.map(body, HsHostingAssetEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
 
-        final var saved = assetRepo.save(entityToSave);
+        final var saved = assetRepo.save(valid(entityToSave));
 
         final var uri =
                 MvcUriComponentsBuilder.fromController(getClass())
@@ -120,6 +122,14 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
         return ResponseEntity.ok(mapped);
     }
 
+    private HsHostingAssetEntity valid(final HsHostingAssetEntity entityToSave) {
+        final var violations = HsHostingAssetValidator.forType(entityToSave.getType()).validate(entityToSave);
+        if (!violations.isEmpty()) {
+            throw new ValidationException(violations.toString());
+        }
+        return entityToSave;
+    }
+
     @SuppressWarnings("unchecked")
     final BiConsumer<HsHostingAssetInsertResource, HsHostingAssetEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
         entity.putConfig(KeyValueMap.from(resource.getConfig()));
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java
new file mode 100644
index 00000000..8a3f1523
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java
@@ -0,0 +1,39 @@
+package net.hostsharing.hsadminng.hs.hosting.asset;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.validator.HsHostingAssetValidator;
+import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetPropsApi;
+import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetTypeResource;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+
+@RestController
+public class HsHostingAssetPropsController implements HsHostingAssetPropsApi {
+
+    @Override
+    public ResponseEntity<List<String>> listAssetTypes() {
+        final var resource = HsHostingAssetValidator.types().stream()
+                .map(Enum::name)
+                .toList();
+        return ResponseEntity.ok(resource);
+    }
+
+    @Override
+    public ResponseEntity<List<Object>> listAssetTypeProps(
+            final HsHostingAssetTypeResource assetType) {
+
+        final var propValidators = HsHostingAssetValidator.forType(HsHostingAssetType.of(assetType));
+        final List<Map<String, Object>> resource = propValidators.properties();
+        return ResponseEntity.ok(toListOfObjects(resource));
+    }
+
+    private List<Object> toListOfObjects(final List<Map<String, Object>> resource) {
+        // OpenApi ony generates List<Object> not List<Map<String, Object>> for the Java interface.
+        // But Spring properly converts the List of Maps, thus we can simply cast the type:
+        //noinspection rawtypes,unchecked
+        return (List) resource;
+    }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validator/HsHostingAssetPropertyValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validator/HsHostingAssetPropertyValidator.java
new file mode 100644
index 00000000..7e61845f
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validator/HsHostingAssetPropertyValidator.java
@@ -0,0 +1,172 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validator;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.Setter;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
+
+import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@RequiredArgsConstructor
+public abstract class HsHostingAssetPropertyValidator<T> {
+
+    final Class<T> type;
+    final String propertyName;
+    private Boolean required;
+
+    public static <K, V> Map.Entry<K, V> defType(K k, V v) {
+        return new SimpleImmutableEntry<>(k, v);
+    }
+
+    public HsHostingAssetPropertyValidator<T> required() {
+        required = Boolean.TRUE;
+        return this;
+    }
+
+    public HsHostingAssetPropertyValidator<T> optional() {
+        required = Boolean.FALSE;
+        return this;
+    }
+
+    public final List<String> validate(final Map<String, Object> props) {
+        final var result = new ArrayList<String>();
+        final var propValue = props.get(propertyName);
+        if (propValue == null) {
+            if (required) {
+                result.add("'" + propertyName + "' is required but missing");
+            }
+        }
+        if (propValue != null){
+            if ( type.isInstance(propValue)) {
+                //noinspection unchecked
+                validate(result, (T) propValue, props);
+            } else {
+                result.add("'" + propertyName + "' is expected to be of type " + type + ", " +
+                        "but is of type '" + propValue.getClass().getSimpleName() + "'");
+            }
+        }
+        return result;
+    }
+
+    protected abstract void validate(final ArrayList<String> result, final T propValue, final Map<String, Object> props);
+
+    public void verifyConsistency(final Map.Entry<HsHostingAssetType, HsHostingAssetValidator> typeDef) {
+        if (required == null ) {
+            throw new IllegalStateException(typeDef.getKey() + "[" + propertyName + "] not fully initialized, please call either .required() or .optional()" );
+        }
+    }
+
+    public Map<String, Object> toMap(final ObjectMapper mapper) {
+        final Map<String, Object> map = mapper.convertValue(this, Map.class);
+        map.put("type", simpleTypeName());
+        return map;
+    }
+
+    protected abstract String simpleTypeName();
+}
+
+@Setter
+class IntegerPropertyValidator extends HsHostingAssetPropertyValidator<Integer>{
+
+    private String unit;
+    private Integer min;
+    private Integer max;
+    private Integer step;
+
+    public static IntegerPropertyValidator integerProperty(final String propertyName) {
+        return new IntegerPropertyValidator(propertyName);
+    }
+
+    private IntegerPropertyValidator(final String propertyName) {
+        super(Integer.class, propertyName);
+    }
+
+
+    @Override
+    protected void validate(final ArrayList<String> result, final Integer propValue, final Map<String, Object> props) {
+        if (min != null && propValue < min) {
+            result.add("'" + propertyName + "' is expected to be >= " + min + " but is " + propValue);
+        }
+        if (max != null && propValue > max) {
+            result.add("'" + propertyName + "' is expected to be <= " + max + " but is " + propValue);
+        }
+        if (step != null && propValue % step != 0) {
+            result.add("'" + propertyName + "' is expected to be multiple of " + step + " but is " + propValue);
+        }
+    }
+
+    @Override
+    protected String simpleTypeName() {
+        return "integer";
+    }
+}
+
+@Setter
+class EnumPropertyValidator extends HsHostingAssetPropertyValidator<String> {
+
+    private String[] values;
+
+    private EnumPropertyValidator(final String propertyName) {
+        super(String.class, propertyName);
+    }
+
+    public static EnumPropertyValidator enumerationProperty(final String propertyName) {
+        return new EnumPropertyValidator(propertyName);
+    }
+
+    public HsHostingAssetPropertyValidator<String> values(final String... values) {
+        this.values = values;
+        return this;
+    }
+
+    @Override
+    protected void validate(final ArrayList<String> result, final String propValue, final Map<String, Object> props) {
+        if (Arrays.stream(values).noneMatch(v -> v.equals(propValue))) {
+            result.add("'" + propertyName + "' is expected to be one of " + Arrays.toString(values) + " but is '" + propValue + "'");
+        }
+    }
+
+    @Override
+    protected String simpleTypeName() {
+        return "enumeration";
+    }
+}
+
+@Setter
+class BooleanPropertyValidator extends HsHostingAssetPropertyValidator<Boolean> {
+
+    private Map.Entry<String, String> falseIf;
+
+    private BooleanPropertyValidator(final String propertyName) {
+        super(Boolean.class, propertyName);
+    }
+
+    public static BooleanPropertyValidator booleanProperty(final String propertyName) {
+        return new BooleanPropertyValidator(propertyName);
+    }
+
+    HsHostingAssetPropertyValidator<Boolean> falseIf(final String refPropertyName, final String refPropertyValue) {
+        this.falseIf = new SimpleImmutableEntry<>(refPropertyName, refPropertyValue);
+        return this;
+    }
+
+    @Override
+    protected void validate(final ArrayList<String> result, final Boolean propValue, final Map<String, Object> props) {
+        if (falseIf != null && !Objects.equals(props.get(falseIf.getKey()), falseIf.getValue())) {
+            if (propValue) {
+                result.add("'" + propertyName + "' is expected to be false because " +
+                        falseIf.getKey()+ "=" + falseIf.getValue() + " but is " + propValue);
+            }
+        }
+    }
+
+    @Override
+    protected String simpleTypeName() {
+        return "boolean";
+    }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validator/HsHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validator/HsHostingAssetValidator.java
new file mode 100644
index 00000000..1389de21
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validator/HsHostingAssetValidator.java
@@ -0,0 +1,99 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validator;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static java.util.Arrays.stream;
+import static net.hostsharing.hsadminng.hs.hosting.asset.validator.EnumPropertyValidator.enumerationProperty;
+import static net.hostsharing.hsadminng.hs.hosting.asset.validator.HsHostingAssetPropertyValidator.defType;
+import static net.hostsharing.hsadminng.hs.hosting.asset.validator.BooleanPropertyValidator.booleanProperty;
+import static net.hostsharing.hsadminng.hs.hosting.asset.validator.IntegerPropertyValidator.integerProperty;
+
+public class HsHostingAssetValidator {
+
+    private static final Map<HsHostingAssetType, HsHostingAssetValidator> validators = Map.ofEntries(
+            defType(HsHostingAssetType.CLOUD_SERVER, new HsHostingAssetValidator(
+                    integerProperty("CPUs").min(1).max(32).required(),
+                    integerProperty("RAM").unit("GB").min(1).max(128).required(),
+                    integerProperty("SSD").unit("GB").min(25).max(1000).step(25).required(),
+                    integerProperty("HDD").unit("GB").min(0).max(4000).step(250).optional(),
+                    integerProperty("Traffic").unit("GB").min(250).max(10000).step(250).required(),
+                    enumerationProperty("SLA-Infrastructure").values("BASIC", "EXT8H", "EXT4H", "EXT2H").optional())),
+            defType(HsHostingAssetType.MANAGED_SERVER, new HsHostingAssetValidator(
+                    integerProperty("CPUs").min(1).max(32).required(),
+                    integerProperty("RAM").unit("GB").min(1).max(128).required(),
+                    integerProperty("SSD").unit("GB").min(25).max(1000).step(25).required(),
+                    integerProperty("HDD").unit("GB").min(0).max(4000).step(250).optional(),
+                    integerProperty("Traffic").unit("GB").min(250).max(10000).step(250).required(),
+                    enumerationProperty("SLA-Platform").values("BASIC", "EXT8H", "EXT4H", "EXT2H").optional(),
+                    booleanProperty("SLA-EMail").falseIf("SLA-Platform", "BASIC").optional(),
+                    booleanProperty("SLA-Maria").falseIf("SLA-Platform", "BASIC").optional(),
+                    booleanProperty("SLA-PgSQL").falseIf("SLA-Platform", "BASIC").optional(),
+                    booleanProperty("SLA-Office").falseIf("SLA-Platform", "BASIC").optional(),
+                    booleanProperty("SLA-Web").falseIf("SLA-Platform", "BASIC").optional())),
+            defType(HsHostingAssetType.MANAGED_WEBSPACE, new HsHostingAssetValidator(
+                    integerProperty("SSD").unit("GB").min(1).max(100).step(1).required(),
+                    integerProperty("HDD").unit("GB").min(0).max(250).step(10).optional(),
+                    integerProperty("Traffic").unit("GB").min(10).max(1000).step(10).required(),
+                    enumerationProperty("SLA-Platform").values("BASIC", "EXT24H").optional(),
+                    integerProperty("Daemons").min(0).max(10).optional(),
+                    booleanProperty("Online Office Server").optional())
+            ));
+    static {
+        validators.entrySet().forEach(typeDef -> {
+            stream(typeDef.getValue().propertyValidators).forEach( entry -> {
+                entry.verifyConsistency(typeDef);
+            });
+        });
+    }
+    private final HsHostingAssetPropertyValidator<?>[] propertyValidators;
+
+    public static HsHostingAssetValidator forType(final HsHostingAssetType type) {
+        return validators.get(type);
+    }
+
+    HsHostingAssetValidator(final HsHostingAssetPropertyValidator<?>... validators) {
+        propertyValidators = validators;
+    }
+
+    public static Set<HsHostingAssetType> types() {
+        return validators.keySet();
+    }
+
+    public List<String> validate(final HsHostingAssetEntity assetEntity) {
+        final var result = new ArrayList<String>();
+        assetEntity.getConfig().keySet().forEach( givenPropName -> {
+            if (stream(propertyValidators).map(pv -> pv.propertyName).noneMatch(propName -> propName.equals(givenPropName))) {
+                result.add("'" + givenPropName + "' is not expected but is '" +assetEntity.getConfig().get(givenPropName) + "'");
+            }
+        });
+        stream(propertyValidators).forEach(pv -> {
+          result.addAll(pv.validate(assetEntity.getConfig()));
+        });
+        return result;
+    }
+
+    public List<Map<String, Object>> properties() {
+        final var mapper = new ObjectMapper();
+        mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
+        return Arrays.stream(propertyValidators)
+                .map(propertyValidator -> propertyValidator.toMap(mapper))
+                .map(HsHostingAssetValidator::asKeyValueMap)
+                .toList();
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    private static Map<String, Object> asKeyValueMap(final Map map) {
+        return (Map<String, Object>) map;
+    }
+
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validator/lombok.config b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validator/lombok.config
new file mode 100644
index 00000000..18183936
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validator/lombok.config
@@ -0,0 +1,3 @@
+lombok.addLombokGeneratedAnnotation = true
+lombok.accessors.chain = true
+lombok.accessors.fluent = true
diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-schemas.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-schemas.yaml
index f3ecb6a3..59696a23 100644
--- a/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-schemas.yaml
+++ b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-schemas.yaml
@@ -80,18 +80,85 @@ components:
             # forces generating a java.lang.Object containing a Map, instead of class AssetConfiguration
             anyOf:
                 - type: object
-                  properties:
-                    CPU:
-                        type: integer
-                        minimum: 1
-                        maximum: 16
-                    SSD:
-                        type: integer
-                        minimum: 16
-                        maximum: 4096
-                    HDD:
-                        type: integer
-                        minimum: 16
-                        maximum: 4096
+                    # single source of supported properties just via /api/hs/hosting/asset-types/{assetType}
+                    # TODO.impl: later, we could generate the config types and their properties from the validation config
                   additionalProperties: true
 
+        PropertyDescriptor:
+            type: object
+            properties:
+                "type":
+                    type: string
+                    enum:
+                        - integer
+                        - boolean
+                        - enumeration
+                "propertyName":
+                    type: string
+                    pattern: "^[ a-zA-Z0-9_-]$"
+                "required":
+                    type: boolean
+            required:
+                - type
+                - propertyName
+                - required
+
+        IntegerPropertyDescriptor:
+            allOf:
+                - $ref: '#/components/schemas/PropertyDescriptor'
+                - type: object
+                  properties:
+                    "type":
+                        type: string
+                        enum:
+                            - integer
+                    "unit":
+                        type: string
+                    "min":
+                        type: integer
+                        minimum: 0
+                    "max":
+                        type: integer
+                        minimum: 0
+                    "step":
+                        type: integer
+                        minimum: 1
+                  required:
+                      - "type"
+                      - "propertyName"
+                      - "required"
+
+        BooleanPropertyDescriptor:
+            allOf:
+                - $ref: '#/components/schemas/PropertyDescriptor'
+                - type: object
+                  properties:
+                      "type":
+                          type: string
+                          enum:
+                              - boolean
+                      "falseIf":
+                          type: object
+                          anyOf:
+                              - type: object
+                                additionalProperties: true
+
+        EnumerationPropertyDescriptor:
+            allOf:
+                - $ref: '#/components/schemas/PropertyDescriptor'
+                - type: object
+                  properties:
+                    "type":
+                        type: string
+                        enum:
+                            - enumeration
+                    "values":
+                          type: array
+                          items:
+                              type: string
+
+        HsHostingAssetProps:
+            anyOf:
+                - $ref: '#/components/schemas/IntegerPropertyDescriptor'
+                - $ref: '#/components/schemas/BooleanPropertyDescriptor'
+                - $ref: '#/components/schemas/EnumerationPropertyDescriptor'
diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types-props.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types-props.yaml
new file mode 100644
index 00000000..c7723c22
--- /dev/null
+++ b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types-props.yaml
@@ -0,0 +1,26 @@
+get:
+    summary: Returns a list of available asset properties for the given type.
+    description: Returns the list of available properties and their validations for a given asset type.
+    tags:
+        - hs-hosting-asset-props
+    operationId: listAssetTypeProps
+    parameters:
+        - name: assetType
+          in: path
+          required: true
+          schema:
+              $ref: 'hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAssetType'
+          description: The asset type whose properties are to be returned.
+    responses:
+        "200":
+            description: OK
+            content:
+                'application/json':
+                    schema:
+                        type: array
+                        items:
+                            $ref: 'hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAssetProps'
+        "401":
+            $ref: 'error-responses.yaml#/components/responses/Unauthorized'
+        "403":
+            $ref: 'error-responses.yaml#/components/responses/Forbidden'
diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types.yaml
new file mode 100644
index 00000000..f1ab17e0
--- /dev/null
+++ b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types.yaml
@@ -0,0 +1,19 @@
+get:
+    summary: Returns a list of available asset types.
+    description: Returns the list of asset types to enable an adaptive UI.
+    tags:
+        - hs-hosting-asset-props
+    operationId: listAssetTypes
+    responses:
+        "200":
+            description: OK
+            content:
+                'application/json':
+                    schema:
+                        type: array
+                        items:
+                            type: string
+        "401":
+            $ref: 'error-responses.yaml#/components/responses/Unauthorized'
+        "403":
+            $ref: 'error-responses.yaml#/components/responses/Forbidden'
diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting-assets.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting-assets.yaml
index 8b81ecc7..a08a36a1 100644
--- a/src/main/resources/api-definition/hs-hosting/hs-hosting-assets.yaml
+++ b/src/main/resources/api-definition/hs-hosting/hs-hosting-assets.yaml
@@ -13,18 +13,20 @@ get:
           schema:
               type: string
               format: uuid
+          description: The UUID of the debitor, whose hosting assets are to be listed.
         - name: parentAssetUuid
           in: query
           required: false
           schema:
               type: string
               format: uuid
+          description: The UUID of the parentAsset, whose hosting assets are to be listed.
         - name: type
           in: query
           required: false
           schema:
               $ref: 'hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAssetType'
-          description: The UUID of the debitor, whose hosting assets are to be listed.
+          description: The type of hosting assets to be listed.
     responses:
         "200":
             description: OK
diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting.yaml
index 4f8f29d5..b0df69dc 100644
--- a/src/main/resources/api-definition/hs-hosting/hs-hosting.yaml
+++ b/src/main/resources/api-definition/hs-hosting/hs-hosting.yaml
@@ -8,10 +8,18 @@ servers:
 
 paths:
 
-  # Items
+  # Assets
 
   /api/hs/hosting/assets:
     $ref: "hs-hosting-assets.yaml"
 
   /api/hs/hosting/assets/{assetUuid}:
     $ref: "hs-hosting-assets-with-uuid.yaml"
+
+  # Asset-Types
+
+  /api/hs/hosting/asset-types:
+    $ref: "hs-hosting-asset-types.yaml"
+
+  /api/hs/hosting/asset-types/{assetType}:
+    $ref: "hs-hosting-asset-types-props.yaml"
diff --git a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java
index 15f9c152..0cb1a086 100644
--- a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java
@@ -52,6 +52,7 @@ public class ArchitectureTest {
                     "..hs.office.sepamandate",
                     "..hs.booking.item",
                     "..hs.hosting.asset",
+                    "..hs.hosting.asset.validator",
                     "..errors",
                     "..mapper",
                     "..ping",
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java
index 26d1b763..0cde4075 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java
@@ -174,7 +174,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
                                 "type": "MANAGED_SERVER",
                                 "identifier": "vm1400",
                                 "caption": "some new CloudServer",
-                                "config": { "CPU": 3, "extra": 42 }
+                                "config": { "CPUs": 2, "RAM": 100, "SSD": 300, "Traffic": 250 }
                             }
                             """.formatted(givenBookingItem.getUuid()))
                         .port(port)
@@ -188,7 +188,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
                                 "type": "MANAGED_SERVER",
                                 "identifier": "vm1400",
                                 "caption": "some new CloudServer",
-                                "config": { "CPU": 3, "extra": 42 }
+                                "config": { "CPUs": 2, "RAM": 100, "SSD": 300, "Traffic": 250 }
                             }
                             """))
                         .header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/hosting/assets/[^/]*"))
@@ -199,6 +199,39 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
                     location.substring(location.lastIndexOf('/') + 1));
             assertThat(newUserUuid).isNotNull();
         }
+
+        @Test
+        void additionalValidationsArePerformend_whenAddingAsset() {
+
+            context.define("superuser-alex@hostsharing.net");
+            final var givenBookingItem = givenBookingItem("First", "some PrivateCloud");
+
+            final var location = RestAssured // @formatter:off
+                    .given()
+                    .header("current-user", "superuser-alex@hostsharing.net")
+                    .contentType(ContentType.JSON)
+                    .body("""
+                            {
+                                "bookingItemUuid": "%s",
+                                "type": "MANAGED_SERVER",
+                                "identifier": "vm1400",
+                                "caption": "some new CloudServer",
+                                "config": { "CPUs": 0, "extra": 42 }
+                            }
+                            """.formatted(givenBookingItem.getUuid()))
+                    .port(port)
+                    .when()
+                    .post("http://localhost/api/hs/hosting/assets")
+                    .then().log().all().assertThat()
+                    .statusCode(400)
+                    .contentType(ContentType.JSON)
+                    .body("", lenientlyEquals("""
+                            {
+                                "statusPhrase": "Bad Request",
+                                "message": "['extra' is not expected but is '42', 'CPUs' is expected to be >= 1 but is 0, 'RAM' is required but missing, 'SSD' is required but missing, 'Traffic' is required but missing]"
+                            }
+                            """));  // @formatter:on
+        }
     }
 
     @Nested
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsControllerAcceptanceTest.java
new file mode 100644
index 00000000..58c7bf91
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsControllerAcceptanceTest.java
@@ -0,0 +1,156 @@
+package net.hostsharing.hsadminng.hs.hosting.asset;
+
+import io.restassured.RestAssured;
+import net.hostsharing.hsadminng.HsadminNgApplication;
+import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+
+import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals;
+
+@SpringBootTest(
+        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+        classes = { HsadminNgApplication.class, JpaAttempt.class }
+)
+class HsHostingAssetPropsControllerAcceptanceTest {
+
+    @LocalServerPort
+    private Integer port;
+
+    @Test
+    void anyone_canListAvailableAssetTypes() {
+
+        RestAssured // @formatter:off
+                .given()
+                    .port(port)
+                .when()
+                    .get("http://localhost/api/hs/hosting/asset-types")
+                .then().log().all().assertThat()
+                    .statusCode(200)
+                    .contentType("application/json")
+                    .body("", lenientlyEquals("""
+                        [
+                            "MANAGED_SERVER",
+                            "MANAGED_WEBSPACE",
+                            "CLOUD_SERVER"
+                        ]
+                        """));
+        // @formatter:on
+    }
+
+    @Test
+    void globalAdmin_canListPropertiesOfGivenAssetType() {
+
+        RestAssured // @formatter:off
+                .given()
+                    .port(port)
+                .when()
+                    .get("http://localhost/api/hs/hosting/asset-types/" + HsHostingAssetType.MANAGED_SERVER)
+                .then().log().all().assertThat()
+                    .statusCode(200)
+                    .contentType("application/json")
+                    .body("", lenientlyEquals("""
+                        [
+                             {
+                                 "type": "integer",
+                                 "propertyName": "CPUs",
+                                 "required": true,
+                                 "unit": null,
+                                 "min": 1,
+                                 "max": 32,
+                                 "step": null
+                             },
+                             {
+                                 "type": "integer",
+                                 "propertyName": "RAM",
+                                 "required": true,
+                                 "unit": "GB",
+                                 "min": 1,
+                                 "max": 128,
+                                 "step": null
+                             },
+                             {
+                                 "type": "integer",
+                                 "propertyName": "SSD",
+                                 "required": true,
+                                 "unit": "GB",
+                                 "min": 25,
+                                 "max": 1000,
+                                 "step": 25
+                             },
+                             {
+                                 "type": "integer",
+                                 "propertyName": "HDD",
+                                 "required": false,
+                                 "unit": "GB",
+                                 "min": 0,
+                                 "max": 4000,
+                                 "step": 250
+                             },
+                             {
+                                 "type": "integer",
+                                 "propertyName": "Traffic",
+                                 "required": true,
+                                 "unit": "GB",
+                                 "min": 250,
+                                 "max": 10000,
+                                 "step": 250
+                             },
+                             {
+                                 "type": "enumeration",
+                                 "propertyName": "SLA-Platform",
+                                 "required": false,
+                                 "values": [
+                                     "BASIC",
+                                     "EXT8H",
+                                     "EXT4H",
+                                     "EXT2H"
+                                 ]
+                             },
+                             {
+                                 "type": "boolean",
+                                 "propertyName": "SLA-EMail",
+                                 "required": false,
+                                 "falseIf": {
+                                     "SLA-Platform": "BASIC"
+                                 }
+                             },
+                             {
+                                 "type": "boolean",
+                                 "propertyName": "SLA-Maria",
+                                 "required": false,
+                                 "falseIf": {
+                                     "SLA-Platform": "BASIC"
+                                 }
+                             },
+                             {
+                                 "type": "boolean",
+                                 "propertyName": "SLA-PgSQL",
+                                 "required": false,
+                                 "falseIf": {
+                                     "SLA-Platform": "BASIC"
+                                 }
+                             },
+                             {
+                                 "type": "boolean",
+                                 "propertyName": "SLA-Office",
+                                 "required": false,
+                                 "falseIf": {
+                                     "SLA-Platform": "BASIC"
+                                 }
+                             },
+                             {
+                                 "type": "boolean",
+                                 "propertyName": "SLA-Web",
+                                 "required": false,
+                                 "falseIf": {
+                                    "SLA-Platform": "BASIC"
+                                }
+                            }
+                        ]
+                        """));
+        // @formatter:on
+    }
+
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetValidatorUnitTest.java
new file mode 100644
index 00000000..d7f21222
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetValidatorUnitTest.java
@@ -0,0 +1,97 @@
+package net.hostsharing.hsadminng.hs.hosting.asset;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.validator.HsHostingAssetValidator;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static java.util.Collections.emptyMap;
+import static java.util.Map.entry;
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER;
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class HsHostingAssetValidatorUnitTest {
+
+    @Test
+    void validatesMissingProperties() {
+        // given
+        final var validator = HsHostingAssetValidator.forType(MANAGED_WEBSPACE);
+        final var mangedWebspaceHostingAssetEntity = HsHostingAssetEntity.builder()
+                        .type(MANAGED_WEBSPACE)
+                        .config(emptyMap())
+                        .build();
+
+        // when
+        final var result = validator.validate(mangedWebspaceHostingAssetEntity);
+
+        // then
+        assertThat(result).containsExactlyInAnyOrder(
+                "'SSD' is required but missing",
+                "'Traffic' is required but missing"
+        );
+    }
+
+    @Test
+    void validatesUnknownProperties() {
+        // given
+        final var validator = HsHostingAssetValidator.forType(MANAGED_WEBSPACE);
+        final var mangedWebspaceHostingAssetEntity = HsHostingAssetEntity.builder()
+                .type(MANAGED_WEBSPACE)
+                .config(Map.ofEntries(
+                        entry("HDD", 0),
+                        entry("SSD", 1),
+                        entry("Traffic", 10),
+                        entry("unknown", "some value")
+                ))
+                .build();
+
+        // when
+        final var result = validator.validate(mangedWebspaceHostingAssetEntity);
+
+        // then
+        assertThat(result).containsExactly("'unknown' is not expected but is 'some value'");
+    }
+
+    @Test
+    void validatesDependentProperties() {
+        // given
+        final var validator = HsHostingAssetValidator.forType(MANAGED_SERVER);
+        final var mangedWebspaceHostingAssetEntity = HsHostingAssetEntity.builder()
+                .type(MANAGED_SERVER)
+                .config(Map.ofEntries(
+                        entry("CPUs", 2),
+                        entry("RAM", 25),
+                        entry("SSD", 25),
+                        entry("Traffic", 250),
+                        entry("SLA-EMail", true)
+                ))
+                .build();
+
+        // when
+        final var result = validator.validate(mangedWebspaceHostingAssetEntity);
+
+        // then
+        assertThat(result).containsExactly("'SLA-EMail' is expected to be false because SLA-Platform=BASIC but is true");
+    }
+
+    @Test
+    void validatesValidProperties() {
+        // given
+        final var validator = HsHostingAssetValidator.forType(MANAGED_WEBSPACE);
+        final var mangedWebspaceHostingAssetEntity = HsHostingAssetEntity.builder()
+                .type(MANAGED_WEBSPACE)
+                .config(Map.ofEntries(
+                        entry("HDD", 200),
+                        entry("SSD", 25),
+                        entry("Traffic", 250)
+                ))
+                .build();
+
+        // when
+        final var result = validator.validate(mangedWebspaceHostingAssetEntity);
+
+        // then
+        assertThat(result).isEmpty();
+    }
+}