From 5472a784c65082a1077d43e9c7f982f9b29d7303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20J=C3=A4ckle?= Date: Mon, 8 Jul 2024 17:08:32 +0200 Subject: [PATCH] #1650 refactored too big class DefaultWotThingModelValidation in smaller pieces * strict split between config reading in DefaultWotThingModelValidation and static functionality in Internal* classes --- .../DefaultWotThingModelValidation.java | 1025 ++--------------- .../validation/InternalFeatureValidation.java | 432 +++++++ .../validation/InternalThingValidation.java | 180 +++ .../wot/validation/InternalValidation.java | 443 +++++++ .../wot/validation/config/package-info.java | 18 + 5 files changed, 1194 insertions(+), 904 deletions(-) create mode 100644 wot/validation/src/main/java/org/eclipse/ditto/wot/validation/InternalFeatureValidation.java create mode 100644 wot/validation/src/main/java/org/eclipse/ditto/wot/validation/InternalThingValidation.java create mode 100644 wot/validation/src/main/java/org/eclipse/ditto/wot/validation/InternalValidation.java create mode 100644 wot/validation/src/main/java/org/eclipse/ditto/wot/validation/config/package-info.java diff --git a/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/DefaultWotThingModelValidation.java b/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/DefaultWotThingModelValidation.java index 4a3b666b99..7d6cf36738 100644 --- a/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/DefaultWotThingModelValidation.java +++ b/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/DefaultWotThingModelValidation.java @@ -12,61 +12,45 @@ */ package org.eclipse.ditto.wot.validation; -import java.util.AbstractMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; +import static org.eclipse.ditto.wot.validation.InternalFeatureValidation.enforceFeatureActionPayload; +import static org.eclipse.ditto.wot.validation.InternalFeatureValidation.enforceFeatureEventPayload; +import static org.eclipse.ditto.wot.validation.InternalFeatureValidation.enforceFeatureProperties; +import static org.eclipse.ditto.wot.validation.InternalFeatureValidation.enforceFeaturePropertiesInAllSubmodels; +import static org.eclipse.ditto.wot.validation.InternalFeatureValidation.enforceFeatureProperty; +import static org.eclipse.ditto.wot.validation.InternalFeatureValidation.enforcePresenceOfModeledFeatures; +import static org.eclipse.ditto.wot.validation.InternalFeatureValidation.forbidNonModeledFeatures; +import static org.eclipse.ditto.wot.validation.InternalThingValidation.enforceThingActionPayload; +import static org.eclipse.ditto.wot.validation.InternalThingValidation.enforceThingAttribute; +import static org.eclipse.ditto.wot.validation.InternalThingValidation.enforceThingAttributes; +import static org.eclipse.ditto.wot.validation.InternalThingValidation.enforceThingEventPayload; +import static org.eclipse.ditto.wot.validation.InternalValidation.success; + import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; import javax.annotation.Nullable; import org.eclipse.ditto.base.model.headers.DittoHeaders; -import org.eclipse.ditto.json.JsonKey; -import org.eclipse.ditto.json.JsonObject; import org.eclipse.ditto.json.JsonPointer; import org.eclipse.ditto.json.JsonValue; import org.eclipse.ditto.things.model.Attributes; import org.eclipse.ditto.things.model.Feature; import org.eclipse.ditto.things.model.FeatureProperties; import org.eclipse.ditto.things.model.Features; -import org.eclipse.ditto.wot.model.Actions; -import org.eclipse.ditto.wot.model.DittoWotExtension; -import org.eclipse.ditto.wot.model.Event; -import org.eclipse.ditto.wot.model.Events; -import org.eclipse.ditto.wot.model.Properties; -import org.eclipse.ditto.wot.model.Property; -import org.eclipse.ditto.wot.model.SingleDataSchema; -import org.eclipse.ditto.wot.model.SingleUriAtContext; import org.eclipse.ditto.wot.model.ThingModel; -import org.eclipse.ditto.wot.model.TmOptionalElement; import org.eclipse.ditto.wot.validation.config.TmValidationConfig; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.networknt.schema.output.OutputUnit; /** * Default implementation for WoT ThingModel based validation/enforcement. - * - * TODO TJ refactor class in smaller pieces */ final class DefaultWotThingModelValidation implements WotThingModelValidation { - private static final Logger log = LoggerFactory.getLogger(DefaultWotThingModelValidation.class); - private final TmValidationConfig validationConfig; - private final JsonSchemaTools jsonSchemaTools; - public DefaultWotThingModelValidation(final TmValidationConfig validationConfig) { + DefaultWotThingModelValidation(final TmValidationConfig validationConfig) { this.validationConfig = validationConfig; - jsonSchemaTools = new JsonSchemaTools(); } @Override @@ -76,7 +60,12 @@ public CompletionStage validateThingAttributes(final ThingModel thingModel final DittoHeaders dittoHeaders ) { if (validationConfig.getThingValidationConfig().isEnforceAttributes() && attributes != null) { - return enforceThingAttributes(thingModel, attributes, resourcePath, dittoHeaders); + return enforceThingAttributes(thingModel, + attributes, + validationConfig.getThingValidationConfig().isForbidNonModeledAttributes(), + resourcePath, + dittoHeaders + ); } return success(); } @@ -89,7 +78,13 @@ public CompletionStage validateThingAttribute(final ThingModel thingModel, final DittoHeaders dittoHeaders ) { if (validationConfig.getThingValidationConfig().isEnforceAttributes()) { - return enforceThingAttribute(thingModel, attributePointer, attributeValue, resourcePath, dittoHeaders); + return enforceThingAttribute(thingModel, + attributePointer, + attributeValue, + validationConfig.getThingValidationConfig().isForbidNonModeledAttributes(), + resourcePath, + dittoHeaders + ); } return success(); } @@ -102,8 +97,14 @@ public CompletionStage validateThingActionInput(final ThingModel thingMode final DittoHeaders dittoHeaders ) { if (validationConfig.getThingValidationConfig().isEnforceInboxMessagesInput()) { - return enforceThingActionPayload(thingModel, messageSubject, inputPayload, resourcePath, true, - dittoHeaders); + return enforceThingActionPayload(thingModel, + messageSubject, + inputPayload, + validationConfig.getThingValidationConfig().isForbidNonModeledInboxMessages(), + resourcePath, + true, + dittoHeaders + ); } return success(); } @@ -116,8 +117,14 @@ public CompletionStage validateThingActionOutput(final ThingModel thingMod final DittoHeaders dittoHeaders ) { if (validationConfig.getThingValidationConfig().isEnforceInboxMessagesOutput()) { - return enforceThingActionPayload(thingModel, messageSubject, outputPayload, resourcePath, false, - dittoHeaders); + return enforceThingActionPayload(thingModel, + messageSubject, + outputPayload, + validationConfig.getThingValidationConfig().isForbidNonModeledInboxMessages(), + resourcePath, + false, + dittoHeaders + ); } return success(); } @@ -130,7 +137,13 @@ public CompletionStage validateThingEventData(final ThingModel thingModel, final DittoHeaders dittoHeaders ) { if (validationConfig.getThingValidationConfig().isEnforceOutboxMessages()) { - return enforceThingEventPayload(thingModel, messageSubject, dataPayload, resourcePath, dittoHeaders); + return enforceThingEventPayload(thingModel, + messageSubject, + dataPayload, + validationConfig.getThingValidationConfig().isForbidNonModeledOutboxMessages(), + resourcePath, + dittoHeaders + ); } return success(); } @@ -140,45 +153,16 @@ public CompletionStage validateFeaturesPresence(final Map definedFeatureIds = featureThingModels.keySet(); - final Set existingFeatures = Optional.ofNullable(features) - .map(Features::stream) - .orElseGet(Stream::empty) - .map(Feature::getId) - .collect(Collectors.toCollection(LinkedHashSet::new)); - final CompletableFuture firstStage; if (validationConfig.getFeatureValidationConfig().isEnforcePresenceOfModeledFeatures()) { - if (!existingFeatures.containsAll(definedFeatureIds)) { - final LinkedHashSet missingFeatureIds = new LinkedHashSet<>(definedFeatureIds); - missingFeatureIds.removeAll(existingFeatures); - final var exceptionBuilder = WotThingModelPayloadValidationException - .newBuilder("Attempting to update the Thing with missing in the model " + - "defined features: " + missingFeatureIds); - firstStage = CompletableFuture.failedFuture(exceptionBuilder - .dittoHeaders(dittoHeaders) - .build()); - } else { - firstStage = success(); - } + firstStage = enforcePresenceOfModeledFeatures(features, featureThingModels.keySet(), dittoHeaders); } else { firstStage = success(); } final CompletableFuture secondStage; if (validationConfig.getFeatureValidationConfig().isForbidNonModeledFeatures()) { - final LinkedHashSet extraFeatureIds = new LinkedHashSet<>(existingFeatures); - extraFeatureIds.removeAll(definedFeatureIds); - if (!extraFeatureIds.isEmpty()) { - final var exceptionBuilder = WotThingModelPayloadValidationException - .newBuilder("Attempting to update the Thing with feature(s) are were not " + - "defined in the model: " + extraFeatureIds); - secondStage = CompletableFuture.failedFuture(exceptionBuilder - .dittoHeaders(dittoHeaders) - .build()); - } else { - secondStage = success(); - } + secondStage = forbidNonModeledFeatures(features, featureThingModels.keySet(), dittoHeaders); } else { secondStage = success(); } @@ -187,60 +171,41 @@ public CompletionStage validateFeaturesPresence(final Map validateFeaturesProperties(final Map featureThingModels, - final @Nullable Features features, + @Nullable final Features features, final JsonPointer resourcePath, final DittoHeaders dittoHeaders ) { - final CompletableFuture> enforcedPropertiesListFuture; + final CompletableFuture firstStage; if (validationConfig.getFeatureValidationConfig().isEnforceProperties() && features != null) { - final List> enforcedPropertiesFutures = featureThingModels - .entrySet() - .stream() - .filter(entry -> features.getFeature(entry.getKey()).isPresent()) - .map(entry -> - enforceFeatureProperties(entry.getValue(), - features.getFeature(entry.getKey()).orElseThrow(), - false, - validationConfig.getFeatureValidationConfig().isForbidNonModeledProperties(), - resourcePath, - dittoHeaders - ) - ) - .toList(); - enforcedPropertiesListFuture = - CompletableFuture.allOf(enforcedPropertiesFutures.toArray(new CompletableFuture[0])) - .thenApply(ignored -> enforcedPropertiesFutures.stream() - .map(CompletableFuture::join) - .toList() - ); + firstStage = enforceFeaturePropertiesInAllSubmodels( + featureThingModels, + features, + false, + validationConfig.getFeatureValidationConfig().isForbidNonModeledProperties(), + resourcePath, + dittoHeaders + ).thenApply(aVoid -> null); } else { - enforcedPropertiesListFuture = success(); + firstStage = success(); } + final CompletableFuture secondStage; if (validationConfig.getFeatureValidationConfig().isEnforceDesiredProperties() && features != null) { - final List> enforcedDesiredPropertiesFutures = featureThingModels - .entrySet() - .stream() - .map(entry -> enforceFeatureProperties(entry.getValue(), - features.getFeature(entry.getKey()).orElseThrow(), - true, - validationConfig.getFeatureValidationConfig().isForbidNonModeledDesiredProperties(), - resourcePath, - dittoHeaders - ) - ) - .toList(); - return enforcedPropertiesListFuture.thenCompose(voidL -> - CompletableFuture.allOf(enforcedDesiredPropertiesFutures.toArray(new CompletableFuture[0])) - .thenApply(ignored -> enforcedDesiredPropertiesFutures.stream() - .map(CompletableFuture::join) - .toList() - ) - ).thenApply(voidL -> null); + secondStage = enforceFeaturePropertiesInAllSubmodels( + featureThingModels, + features, + true, + validationConfig.getFeatureValidationConfig().isForbidNonModeledDesiredProperties(), + resourcePath, + dittoHeaders + ).thenApply(aVoid -> null); + } else { + secondStage = success(); } - return enforcedPropertiesListFuture.thenApply(voidL -> null); + return firstStage.thenCompose(unused -> secondStage); } + @Override public CompletionStage validateFeaturePresence(final Map featureThingModels, final Feature feature, @@ -248,23 +213,16 @@ public CompletionStage validateFeaturePresence(final Map definedFeatureIds = featureThingModels.keySet(); final String featureId = feature.getId(); - - final CompletableFuture stage; - if (validationConfig.getFeatureValidationConfig().isForbidNonModeledFeatures()) { - if (!definedFeatureIds.contains(featureId)) { - final var exceptionBuilder = WotThingModelPayloadValidationException - .newBuilder("Attempting to update the Thing with a feature which is not " + - "defined in the model: <" + featureId + ">"); - stage = CompletableFuture.failedFuture(exceptionBuilder - .dittoHeaders(dittoHeaders) - .build()); - } else { - stage = success(); - } - } else { - stage = success(); + if (validationConfig.getFeatureValidationConfig().isForbidNonModeledFeatures() && + !definedFeatureIds.contains(featureId)) { + final var exceptionBuilder = WotThingModelPayloadValidationException + .newBuilder("Attempting to update the Thing with a feature which is not " + + "defined in the model: <" + featureId + ">"); + return CompletableFuture.failedFuture(exceptionBuilder + .dittoHeaders(dittoHeaders) + .build()); } - return stage; + return success(); } @Override @@ -273,9 +231,9 @@ public CompletionStage validateFeature(final ThingModel featureThingModel, final JsonPointer resourcePath, final DittoHeaders dittoHeaders ) { - final CompletableFuture enforcedPropertiesFuture; + final CompletableFuture firstStage; if (validationConfig.getFeatureValidationConfig().isEnforceProperties()) { - enforcedPropertiesFuture = enforceFeatureProperties(featureThingModel, + firstStage = enforceFeatureProperties(featureThingModel, feature, false, validationConfig.getFeatureValidationConfig().isForbidNonModeledProperties(), @@ -283,21 +241,22 @@ public CompletionStage validateFeature(final ThingModel featureThingModel, dittoHeaders ); } else { - enforcedPropertiesFuture = success(); + firstStage = success(); } + final CompletableFuture secondStage; if (validationConfig.getFeatureValidationConfig().isEnforceDesiredProperties()) { - return enforcedPropertiesFuture.thenCompose(aVoid -> - enforceFeatureProperties(featureThingModel, - feature, - true, - validationConfig.getFeatureValidationConfig().isForbidNonModeledDesiredProperties(), - resourcePath, - dittoHeaders - ) + secondStage = enforceFeatureProperties(featureThingModel, + feature, + true, + validationConfig.getFeatureValidationConfig().isForbidNonModeledDesiredProperties(), + resourcePath, + dittoHeaders ); + } else { + secondStage = success(); } - return enforcedPropertiesFuture; + return firstStage.thenCompose(unused -> secondStage); } @Override @@ -362,8 +321,15 @@ public CompletionStage validateFeatureActionInput(final ThingModel feature final DittoHeaders dittoHeaders ) { if (validationConfig.getFeatureValidationConfig().isEnforceInboxMessagesInput()) { - return enforceFeatureActionPayload(featureId, featureThingModel, messageSubject, inputPayload, - resourcePath, true, dittoHeaders); + return enforceFeatureActionPayload(featureId, + featureThingModel, + messageSubject, + inputPayload, + validationConfig.getFeatureValidationConfig().isForbidNonModeledInboxMessages(), + resourcePath, + true, + dittoHeaders + ); } return success(); } @@ -377,8 +343,15 @@ public CompletionStage validateFeatureActionOutput(final ThingModel featur final DittoHeaders dittoHeaders ) { if (validationConfig.getFeatureValidationConfig().isEnforceInboxMessagesOutput()) { - return enforceFeatureActionPayload(featureId, featureThingModel, messageSubject, outputPayload, - resourcePath, false, dittoHeaders); + return enforceFeatureActionPayload(featureId, + featureThingModel, + messageSubject, + outputPayload, + validationConfig.getFeatureValidationConfig().isForbidNonModeledInboxMessages(), + resourcePath, + false, + dittoHeaders + ); } return success(); } @@ -392,772 +365,16 @@ public CompletionStage validateFeatureEventData(final ThingModel featureTh final DittoHeaders dittoHeaders ) { if (validationConfig.getFeatureValidationConfig().isEnforceOutboxMessages()) { - return enforceFeatureEventPayload(featureId, featureThingModel, messageSubject, dataPayload, - resourcePath, dittoHeaders); - } - return success(); - } - - private CompletableFuture enforceThingAttributes(final ThingModel thingModel, - final Attributes attributes, - final JsonPointer resourcePath, - final DittoHeaders dittoHeaders - ) { - return thingModel.getProperties() - .map(tdProperties -> { - final String containerNamePlural = "Thing's attributes"; - final CompletableFuture ensureRequiredPropertiesStage = - ensureRequiredProperties(thingModel, dittoHeaders, tdProperties, attributes, - containerNamePlural, "Thing's attribute", - resourcePath, false); - - final CompletableFuture ensureOnlyDefinedPropertiesStage; - if (validationConfig.getThingValidationConfig().isForbidNonModeledAttributes()) { - ensureOnlyDefinedPropertiesStage = ensureOnlyDefinedProperties(thingModel, dittoHeaders, - tdProperties, attributes, containerNamePlural, false); - } else { - ensureOnlyDefinedPropertiesStage = success(); - } - - final CompletableFuture validatePropertiesStage = - getValidatePropertiesStage(thingModel, dittoHeaders, tdProperties, attributes, - true, containerNamePlural, resourcePath, false); - - return CompletableFuture.allOf( - ensureRequiredPropertiesStage, - ensureOnlyDefinedPropertiesStage, - validatePropertiesStage - ); - }).orElseGet(DefaultWotThingModelValidation::success); - } - - private CompletableFuture enforceThingAttribute(final ThingModel thingModel, - final JsonPointer attributePath, - final JsonValue attributeValue, - final JsonPointer resourcePath, - final DittoHeaders dittoHeaders - ) { - - return thingModel.getProperties() - .map(tdProperties -> { - final Attributes attributes = Attributes.newBuilder().set(attributePath, attributeValue).build(); - final CompletableFuture ensureOnlyDefinedPropertiesStage; - if (validationConfig.getFeatureValidationConfig().isForbidNonModeledProperties()) { - ensureOnlyDefinedPropertiesStage = ensureOnlyDefinedProperties(thingModel, dittoHeaders, - tdProperties, attributes, "Thing's attributes", false); - } else { - ensureOnlyDefinedPropertiesStage = success(); - } - - final CompletableFuture validatePropertiesStage = - getValidatePropertyStage(thingModel, dittoHeaders, tdProperties, attributePath, - true, attributeValue, - "Thing's attribute <" + attributePath + ">", resourcePath, false - ); - - return CompletableFuture.allOf( - ensureOnlyDefinedPropertiesStage, - validatePropertiesStage - ); - }).orElseGet(DefaultWotThingModelValidation::success); - } - - private CompletableFuture enforceThingActionPayload(final ThingModel thingModel, - final String messageSubject, - @Nullable final JsonValue payload, - final JsonPointer resourcePath, - final boolean isInput, - final DittoHeaders dittoHeaders - ) { - final CompletableFuture ensureOnlyDefinedActionsStage; - if (isInput && validationConfig.getThingValidationConfig().isForbidNonModeledInboxMessages()) { - ensureOnlyDefinedActionsStage = ensureOnlyDefinedActions(dittoHeaders, - thingModel.getActions().orElse(null), messageSubject, "Thing's"); - } else { - ensureOnlyDefinedActionsStage = success(); - } - return doEnforceActionPayload(thingModel, messageSubject, payload, resourcePath, isInput, - "Thing's action <" + messageSubject + "> " + (isInput ? "input" : "output"), - dittoHeaders, - ensureOnlyDefinedActionsStage - ); - } - - private CompletableFuture enforceThingEventPayload(final ThingModel thingModel, - final String messageSubject, - @Nullable final JsonValue payload, - final JsonPointer resourcePath, - final DittoHeaders dittoHeaders - ) { - final CompletableFuture ensureOnlyDefinedEventsStage; - if (validationConfig.getThingValidationConfig().isForbidNonModeledOutboxMessages()) { - ensureOnlyDefinedEventsStage = ensureOnlyDefinedEvents(dittoHeaders, - thingModel.getEvents().orElse(null), messageSubject, "Thing's"); - } else { - ensureOnlyDefinedEventsStage = success(); - } - return doEnforceEventPayload(thingModel, messageSubject, payload, resourcePath, - "Thing's event <" + messageSubject + "> data", - dittoHeaders, - ensureOnlyDefinedEventsStage - ); - } - - private CompletableFuture enforceFeatureActionPayload(final String featureId, - final ThingModel featureThingModel, - final String messageSubject, - @Nullable final JsonValue inputPayload, - final JsonPointer resourcePath, - final boolean isInput, - final DittoHeaders dittoHeaders - ) { - final CompletableFuture ensureOnlyDefinedActionsStage; - if (validationConfig.getFeatureValidationConfig().isForbidNonModeledInboxMessages()) { - ensureOnlyDefinedActionsStage = ensureOnlyDefinedActions(dittoHeaders, - featureThingModel.getActions().orElse(null), messageSubject, - "Feature <" + featureId + ">'s"); - } else { - ensureOnlyDefinedActionsStage = success(); - } - return doEnforceActionPayload(featureThingModel, messageSubject, inputPayload, resourcePath, isInput, - "Feature <" + featureId + ">'s action <" + messageSubject + "> " + (isInput ? "input" : "output"), - dittoHeaders, - ensureOnlyDefinedActionsStage - ); - } - - private CompletableFuture doEnforceActionPayload(final ThingModel thingModel, - final String messageSubject, - @Nullable final JsonValue inputPayload, - final JsonPointer resourcePath, - final boolean isInput, - final String validationFailedDescription, - final DittoHeaders dittoHeaders, - final CompletableFuture ensureOnlyDefinedActionsStage - ) { - return thingModel.getActions() - .flatMap(action -> action.getAction(messageSubject)) - .flatMap(action -> isInput ? action.getInput() : action.getOutput()) - .map(schema -> { - final CompletableFuture validatePropertiesStage = validateSingleDataSchema( - schema, - validationFailedDescription, - JsonPointer.empty(), - true, - inputPayload, - resourcePath, - dittoHeaders - ); - return ensureOnlyDefinedActionsStage.thenCompose(aVoid -> - validatePropertiesStage - ); - }) - .orElse(ensureOnlyDefinedActionsStage); - } - - private CompletableFuture enforceFeatureEventPayload(final String featureId, - final ThingModel featureThingModel, - final String messageSubject, - @Nullable final JsonValue payload, - final JsonPointer resourcePath, - final DittoHeaders dittoHeaders - ) { - final CompletableFuture ensureOnlyDefinedEventsStage; - if (validationConfig.getFeatureValidationConfig().isForbidNonModeledOutboxMessages()) { - ensureOnlyDefinedEventsStage = ensureOnlyDefinedEvents(dittoHeaders, - featureThingModel.getEvents().orElse(null), + return enforceFeatureEventPayload(featureId, + featureThingModel, messageSubject, - "Feature <" + featureId + ">'s" - ); - } else { - ensureOnlyDefinedEventsStage = success(); - } - return doEnforceEventPayload(featureThingModel, messageSubject, payload, resourcePath, - "Feature <" + featureId + ">'s event <" + messageSubject + "> data", - dittoHeaders, - ensureOnlyDefinedEventsStage - ); - } - - private CompletableFuture doEnforceEventPayload(final ThingModel thingModel, - final String messageSubject, - @Nullable final JsonValue dataPayload, - final JsonPointer resourcePath, - final String validationFailedDescription, - final DittoHeaders dittoHeaders, - final CompletableFuture ensureOnlyDefinedEventsStage - ) { - return thingModel.getEvents() - .flatMap(event -> event.getEvent(messageSubject)) - .flatMap(Event::getData) - .map(schema -> { - final CompletableFuture validatePropertiesStage = validateSingleDataSchema( - schema, - validationFailedDescription, - JsonPointer.empty(), - true, - dataPayload, - resourcePath, - dittoHeaders - ); - return ensureOnlyDefinedEventsStage.thenCompose(aVoid -> - validatePropertiesStage - ); - }) - .orElse(ensureOnlyDefinedEventsStage); - } - - private CompletableFuture ensureRequiredProperties(final ThingModel thingModel, - final DittoHeaders dittoHeaders, - final Properties tdProperties, - final JsonObject propertiesContainer, - final String containerNamePlural, - final String containerName, - final JsonPointer pointerPrefix, - final boolean handleDittoCategory - ) { - final Map nonProvidedRequiredProperties = - filterNonProvidedRequiredProperties(tdProperties, thingModel, propertiesContainer, handleDittoCategory); - - final CompletableFuture requiredPropertiesStage; - if (!nonProvidedRequiredProperties.isEmpty()) { - final var exceptionBuilder = WotThingModelPayloadValidationException - .newBuilder("Required JSON fields were missing from the " + containerNamePlural); - nonProvidedRequiredProperties.forEach((rpKey, requiredProperty) -> - { - JsonPointer fullPointer = pointerPrefix; - final Optional dittoCategory = determineDittoCategory(thingModel, requiredProperty); - if (handleDittoCategory && dittoCategory.isPresent()) { - fullPointer = fullPointer.addLeaf(JsonKey.of(dittoCategory.get())); - } - fullPointer = fullPointer.addLeaf(JsonKey.of(rpKey)); - exceptionBuilder.addValidationDetail( - fullPointer, - List.of(containerName + " <" + rpKey + "> is non optional and must be provided") - ); - } - ); - requiredPropertiesStage = CompletableFuture - .failedFuture(exceptionBuilder.dittoHeaders(dittoHeaders).build()); - } else { - requiredPropertiesStage = success(); - } - return requiredPropertiesStage; - } - - private static Optional determineDittoCategory(final ThingModel thingModel, final Property property) { - final Optional dittoExtensionPrefix = thingModel.getAtContext() - .determinePrefixFor(SingleUriAtContext.DITTO_WOT_EXTENSION); - return dittoExtensionPrefix.flatMap(prefix -> - property.getValue(prefix + ":" + DittoWotExtension.DITTO_WOT_EXTENSION_CATEGORY) - ) - .filter(JsonValue::isString) - .map(JsonValue::asString); - } - - private static Set determineDittoCategories(final ThingModel thingModel, final Properties properties) { - final Optional dittoExtensionPrefix = thingModel.getAtContext() - .determinePrefixFor(SingleUriAtContext.DITTO_WOT_EXTENSION); - return dittoExtensionPrefix.stream().flatMap(prefix -> - properties.values().stream().flatMap(jsonFields -> - jsonFields.getValue(prefix + ":" + DittoWotExtension.DITTO_WOT_EXTENSION_CATEGORY) - .filter(JsonValue::isString) - .map(JsonValue::asString) - .stream() - ) - ).collect(Collectors.toSet()); - } - - private Map filterNonProvidedRequiredProperties(final Properties tdProperties, - final ThingModel thingModel, - final JsonObject propertiesContainer, - final boolean handleDittoCategory - ) { - final Map requiredProperties = extractRequiredProperties(tdProperties, thingModel); - final Map nonProvidedRequiredProperties = new LinkedHashMap<>(requiredProperties); - if (handleDittoCategory) { - requiredProperties.forEach((rpKey, requiredProperty) -> { - final Optional dittoCategory = determineDittoCategory(thingModel, requiredProperty); - if (dittoCategory.isPresent()) { - propertiesContainer.getValue(dittoCategory.get()) - .filter(JsonValue::isObject) - .map(JsonValue::asObject) - .ifPresent(categorizedProperties -> categorizedProperties.getKeys().stream() - .map(JsonKey::toString) - .forEach(nonProvidedRequiredProperties::remove) - ); - } else { - propertiesContainer.getKeys().stream() - .map(JsonKey::toString) - .forEach(nonProvidedRequiredProperties::remove); - } - }); - } else { - propertiesContainer.getKeys().stream() - .map(JsonKey::toString) - .forEach(nonProvidedRequiredProperties::remove); - } - return nonProvidedRequiredProperties; - } - - private CompletableFuture ensureOnlyDefinedProperties(final ThingModel thingModel, - final DittoHeaders dittoHeaders, - final Properties tdProperties, - final JsonObject propertiesContainer, - final String containerNamePlural, - final boolean handleDittoCategory - ) { - final Set allDefinedPropertyKeys = tdProperties.keySet(); - final Set allAvailablePropertiesKeys = - propertiesContainer.getKeys().stream().map(JsonKey::toString) - .collect(Collectors.toCollection(LinkedHashSet::new)); - if (handleDittoCategory) { - tdProperties.forEach((propertyName, property) -> { - final Optional dittoCategory = determineDittoCategory(thingModel, property); - final String categorizedPropertyName = dittoCategory - .map(c -> c + "/").orElse("") - .concat(propertyName); - if (propertiesContainer.contains(JsonPointer.of(categorizedPropertyName))) { - allAvailablePropertiesKeys.remove(propertyName); - dittoCategory.ifPresent(allAvailablePropertiesKeys::remove); - } - }); - } else { - allAvailablePropertiesKeys.removeAll(allDefinedPropertyKeys); - } - - if (!allAvailablePropertiesKeys.isEmpty()) { - final var exceptionBuilder = WotThingModelPayloadValidationException - .newBuilder("The " + containerNamePlural + " contained " + - "JSON fields which were not defined in the model: " + allAvailablePropertiesKeys); - return CompletableFuture.failedFuture(exceptionBuilder - .dittoHeaders(dittoHeaders) - .build()); - } - return success(); - } - - private CompletableFuture ensureOnlyDefinedActions(final DittoHeaders dittoHeaders, - @Nullable final Actions actions, - final String messageSubject, - final String containerName - ) { - final Set allDefinedActionKeys = Optional.ofNullable(actions).map(Actions::keySet).orElseGet(Set::of); - final boolean messageSubjectIsDefinedAsAction = allDefinedActionKeys.contains(messageSubject); - if (!messageSubjectIsDefinedAsAction) { - final var exceptionBuilder = WotThingModelPayloadValidationException - .newBuilder("The " + containerName + " message subject <" + - messageSubject + "> is not defined as known action in the model: " + allDefinedActionKeys - ); - return CompletableFuture.failedFuture(exceptionBuilder - .dittoHeaders(dittoHeaders) - .build()); - } - return success(); - } - - private CompletableFuture ensureOnlyDefinedEvents(final DittoHeaders dittoHeaders, - @Nullable final Events events, - final String messageSubject, - final String containerName - ) { - final Set allDefinedEventKeys = Optional.ofNullable(events).map(Events::keySet).orElseGet(Set::of); - final boolean messageSubjectIsDefinedAsEvent = allDefinedEventKeys.contains(messageSubject); - if (!messageSubjectIsDefinedAsEvent) { - final var exceptionBuilder = WotThingModelPayloadValidationException - .newBuilder("The " + containerName + " message subject <" + - messageSubject + "> is not defined as known event in the model: " + allDefinedEventKeys - ); - return CompletableFuture.failedFuture(exceptionBuilder - .dittoHeaders(dittoHeaders) - .build()); - } - return success(); - } - - private CompletableFuture getValidatePropertiesStage(final ThingModel thingModel, - final DittoHeaders dittoHeaders, - final Properties tdProperties, - final JsonObject propertiesContainer, - final boolean validateRequiredObjectFields, - final String containerNamePlural, - final JsonPointer pointerPrefix, - final boolean handleDittoCategory - ) { - final CompletableFuture validatePropertiesStage; - final Map invalidProperties; - if (handleDittoCategory) { - invalidProperties = determineInvalidProperties(tdProperties, - p -> propertiesContainer.getValue( - determineDittoCategory(thingModel, p) - .map(c -> c + "/") - .orElse("") - .concat(p.getPropertyName()) - ), - validateRequiredObjectFields, - dittoHeaders - ); - } else { - invalidProperties = determineInvalidProperties(tdProperties, - p -> propertiesContainer.getValue(p.getPropertyName()), - validateRequiredObjectFields, - dittoHeaders - ); - } - - if (!invalidProperties.isEmpty()) { - final var exceptionBuilder = WotThingModelPayloadValidationException - .newBuilder("The " + containerNamePlural + " contained validation errors, " + - "check the validation details."); - invalidProperties.forEach((property, validationOutputUnit) -> { - JsonPointer fullPointer = pointerPrefix; - final Optional dittoCategory = determineDittoCategory(thingModel, property); - if (handleDittoCategory && dittoCategory.isPresent()) { - fullPointer = fullPointer.addLeaf(JsonKey.of(dittoCategory.get())); - } - fullPointer = fullPointer.addLeaf(JsonKey.of(property.getPropertyName())); - exceptionBuilder.addValidationDetail( - fullPointer, - validationOutputUnit.getDetails().stream() - .map(ou -> ou.getInstanceLocation() + ": " + ou.getErrors()) - .toList() - ); - }); - validatePropertiesStage = CompletableFuture.failedFuture(exceptionBuilder - .dittoHeaders(dittoHeaders) - .build()); - } else { - validatePropertiesStage = success(); - } - return validatePropertiesStage; - } - - private CompletableFuture getValidatePropertyStage(final ThingModel thingModel, - final DittoHeaders dittoHeaders, - final Properties tdProperties, - final JsonPointer propertyPath, - final boolean validateRequiredObjectFields, - final JsonValue propertyValue, - final String propertyDescription, - final JsonPointer resourcePath, - final boolean handleDittoCategory - ) { - final JsonValue valueToValidate; - if (propertyPath.getLevelCount() > 1) { - valueToValidate = JsonObject.newBuilder() - .set(propertyPath.getSubPointer(1).orElseThrow(), propertyValue) - .build(); - } else { - valueToValidate = propertyValue; - } - - return tdProperties.values() - .stream() - .filter(property -> { - if (handleDittoCategory) { - final JsonPointer thePropertyPath = determineDittoCategory(thingModel, property) - .flatMap(cat -> propertyPath.getSubPointer(1)) - .orElse(propertyPath); - return property.getPropertyName().equals(thePropertyPath.getRoot().orElseThrow().toString()); - } else { - return property.getPropertyName().equals(propertyPath.getRoot().orElseThrow().toString()); - } - }) - .findFirst() - .map(property -> { - if (handleDittoCategory) { - final Optional dittoCategory = determineDittoCategory(thingModel, property); - final JsonPointer thePropertyPath = dittoCategory - .flatMap(cat -> propertyPath.getSubPointer(1)) - .orElse(propertyPath); - final JsonValue theValueToValidate = dittoCategory - .flatMap(cat -> valueToValidate.asObject().getValue(thePropertyPath)) - .orElse(valueToValidate); - return validateSingleDataSchema( - property, - propertyDescription, - thePropertyPath, - validateRequiredObjectFields, - theValueToValidate, - resourcePath, - dittoHeaders - ); - } else { - return validateSingleDataSchema( - property, - propertyDescription, - propertyPath, - validateRequiredObjectFields, - valueToValidate, - resourcePath, - dittoHeaders - ); - } - }).orElseGet(DefaultWotThingModelValidation::success); - } - - private CompletableFuture validateSingleDataSchema(final SingleDataSchema dataSchema, - final String validatedDescription, - final JsonPointer pointerPath, - final boolean validateRequiredObjectFields, - @Nullable final JsonValue jsonValue, - final JsonPointer resourcePath, - final DittoHeaders dittoHeaders - ) { - final OutputUnit validationOutput = jsonSchemaTools.validateDittoJsonBasedOnDataSchema( - dataSchema, - pointerPath, - validateRequiredObjectFields, - jsonValue, - dittoHeaders - ); - - if (!validationOutput.isValid()) { - final var exceptionBuilder = WotThingModelPayloadValidationException - .newBuilder("The " + validatedDescription + " contained validation errors, " + - "check the validation details."); - exceptionBuilder.addValidationDetail( + dataPayload, + validationConfig.getFeatureValidationConfig().isForbidNonModeledOutboxMessages(), resourcePath, - validationOutput.getDetails().stream() - .map(ou -> ou.getInstanceLocation() + ": " + ou.getErrors()) - .toList() + dittoHeaders ); - return CompletableFuture.failedFuture(exceptionBuilder - .dittoHeaders(dittoHeaders) - .build()); } return success(); } - private Map determineInvalidProperties(final Properties tdProperties, - final Function> propertyExtractor, - final boolean validateRequiredObjectFields, - final DittoHeaders dittoHeaders - ) { - return tdProperties.entrySet().stream() - .flatMap(tdPropertyEntry -> - propertyExtractor.apply(tdPropertyEntry.getValue()) - .map(propertyValue -> new AbstractMap.SimpleEntry<>( - tdPropertyEntry.getValue(), - jsonSchemaTools.validateDittoJsonBasedOnDataSchema( - tdPropertyEntry.getValue(), - JsonPointer.empty(), - validateRequiredObjectFields, - propertyValue, - dittoHeaders - ) - )) - .filter(entry -> !entry.getValue().isValid()) - .stream() - ).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (u, v) -> { - throw new IllegalStateException(String.format("Duplicate key %s", u)); - }, LinkedHashMap::new)); - } - - private CompletableFuture enforceFeatureProperties(final ThingModel featureThingModel, - final Feature feature, - final boolean desiredProperties, - final boolean forbidNonModeledProperties, - final JsonPointer resourcePath, - final DittoHeaders dittoHeaders - ) { - return featureThingModel.getProperties() - .map(tdProperties -> { - final FeatureProperties featureProperties; - if (desiredProperties) { - featureProperties = feature.getDesiredProperties() - .orElseGet(() -> FeatureProperties.newBuilder().build()); - } else { - featureProperties = feature.getProperties() - .orElseGet(() -> FeatureProperties.newBuilder().build()); - } - - final String containerNamePrefix = "Feature <" + feature.getId() + ">'s " + - (desiredProperties ? "desired " : ""); - final String containerNamePlural = containerNamePrefix + "properties"; - - final CompletableFuture ensureRequiredPropertiesStage; - if (!desiredProperties) { - ensureRequiredPropertiesStage = ensureRequiredProperties(featureThingModel, dittoHeaders, - tdProperties, featureProperties, containerNamePlural, - containerNamePrefix + "property", resourcePath, true); - } else { - ensureRequiredPropertiesStage = success(); - } - - final CompletableFuture ensureOnlyDefinedPropertiesStage; - if (forbidNonModeledProperties) { - ensureOnlyDefinedPropertiesStage = ensureOnlyDefinedProperties(featureThingModel, dittoHeaders, - tdProperties, featureProperties, containerNamePlural, true); - } else { - ensureOnlyDefinedPropertiesStage = success(); - } - - final CompletableFuture validatePropertiesStage = - getValidatePropertiesStage(featureThingModel, dittoHeaders, tdProperties, featureProperties, - !desiredProperties, containerNamePlural, resourcePath, true); - - return CompletableFuture.allOf( - ensureRequiredPropertiesStage, - ensureOnlyDefinedPropertiesStage, - validatePropertiesStage - ); - }).orElseGet(DefaultWotThingModelValidation::success); - } - - private CompletableFuture enforceFeatureProperty(final ThingModel featureThingModel, - final String featureId, - final JsonPointer propertyPath, - final JsonValue propertyValue, - final boolean desiredProperty, - final boolean forbidNonModeledProperties, - final JsonPointer resourcePath, - final DittoHeaders dittoHeaders - ) { - final Set categories = determineDittoCategories(featureThingModel, - featureThingModel.getProperties().orElse(Properties.of(Map.of())) - ); - final boolean isCategoryUpdate = propertyPath.getLevelCount() == 1 && - categories.contains(propertyPath.getRoot().orElseThrow().toString()); - - return featureThingModel.getProperties() - .map(tdProperties -> { - final String containerNamePrefix = "Feature <" + featureId + ">'s " + - (desiredProperty ? "desired " : ""); - final String containerNamePlural = containerNamePrefix + "properties"; - - final CompletableFuture ensureOnlyDefinedPropertiesStage; - if (isCategoryUpdate) { - final String dittoCategory = propertyPath.getRoot().orElseThrow().toString(); - if (forbidNonModeledProperties) { - final Properties propertiesInCategory = Properties.from(tdProperties.values().stream() - .filter(property -> - determineDittoCategory(featureThingModel, property) - .filter(cat -> cat.equals(dittoCategory)) - .isPresent() - ) - .toList()); - final FeatureProperties featureProperties = FeatureProperties.newBuilder() - .setAll(propertyValue.asObject()) - .build(); - ensureOnlyDefinedPropertiesStage = - ensureOnlyDefinedProperties(featureThingModel, dittoHeaders, - propertiesInCategory, featureProperties, containerNamePlural, false); - } else { - ensureOnlyDefinedPropertiesStage = success(); - } - } else { - if (forbidNonModeledProperties) { - final FeatureProperties featureProperties = FeatureProperties.newBuilder() - .set(propertyPath, propertyValue) - .build(); - ensureOnlyDefinedPropertiesStage = - ensureOnlyDefinedProperties(featureThingModel, dittoHeaders, - tdProperties, featureProperties, containerNamePlural, true); - } else { - ensureOnlyDefinedPropertiesStage = success(); - } - } - - final CompletableFuture validatePropertiesStage; - if (isCategoryUpdate) { - final String dittoCategory = propertyPath.getRoot().orElseThrow().toString(); - final List sameCategoryProperties = tdProperties.values().stream() - .filter(property -> - // gather all properties from the same category - determineDittoCategory(featureThingModel, property) - .filter(cat -> cat.equals(dittoCategory)) - .isPresent() - ) - .toList(); - - if (!sameCategoryProperties.isEmpty() && propertyValue.isObject()) { - log.debug("Category update of category <{}> with known properties in same category: {}", - propertyPath.getRoot().orElseThrow(), sameCategoryProperties); - validatePropertiesStage = getValidatePropertyCategoryStage( - featureThingModel, dittoHeaders, Properties.from(sameCategoryProperties), - propertyPath, !desiredProperty, propertyValue.asObject(), - containerNamePrefix + "property", resourcePath - ); - } else { - validatePropertiesStage = - getValidatePropertyStage(featureThingModel, dittoHeaders, tdProperties, - propertyPath, - !desiredProperty, propertyValue, - containerNamePrefix + "property <" + propertyPath + ">", - resourcePath, true - ); - } - } else { - validatePropertiesStage = - getValidatePropertyStage(featureThingModel, dittoHeaders, tdProperties, propertyPath, - !desiredProperty, propertyValue, - containerNamePrefix + "property <" + propertyPath + ">", - resourcePath, true - ); - } - - return CompletableFuture.allOf( - ensureOnlyDefinedPropertiesStage, - validatePropertiesStage - ); - }).orElseGet(DefaultWotThingModelValidation::success); - } - - private CompletableFuture getValidatePropertyCategoryStage(final ThingModel featureThingModel, - final DittoHeaders dittoHeaders, - final Properties categoryProperties, - final JsonPointer propertyPath, - final boolean validateRequiredObjectFields, - final JsonObject categoryObject, - final String propertyDescription, - final JsonPointer resourcePath - ) { - final Map nonProvidedRequiredProperties = - filterNonProvidedRequiredProperties(categoryProperties, featureThingModel, categoryObject, false); - final JsonKey category = propertyPath.getRoot().orElseThrow(); - final String propertyCategoryDescription = propertyDescription + " category <" + category + ">"; - if (validateRequiredObjectFields && !nonProvidedRequiredProperties.isEmpty()) { - final var exceptionBuilder = WotThingModelPayloadValidationException - .newBuilder("Required JSON fields were missing from the " + propertyCategoryDescription); - nonProvidedRequiredProperties.forEach((rpKey, requiredProperty) -> - exceptionBuilder.addValidationDetail( - propertyPath.addLeaf(JsonKey.of(rpKey)), - List.of(propertyDescription + " category <" + category + - ">'s <" + rpKey + "> is non optional and must be provided") - ) - ); - return CompletableFuture - .failedFuture(exceptionBuilder.dittoHeaders(dittoHeaders).build()); - } - - return getValidatePropertiesStage( - featureThingModel, - dittoHeaders, - categoryProperties, - categoryObject, - validateRequiredObjectFields, - propertyCategoryDescription, - resourcePath, - false - ); - } - - private static CompletableFuture success() { - return CompletableFuture.completedFuture(null); - } - - private static Map extractRequiredProperties(final Properties tdProperties, - final ThingModel thingModel - ) { - return thingModel.getTmOptional().map(tmOptionalElements -> { - final Map allRequiredProperties = new LinkedHashMap<>(tdProperties); - tmOptionalElements.stream() - .map(TmOptionalElement::toString) - .filter(el -> el.startsWith("/properties/")) - .map(el -> el.replace("/properties/", "")) - .forEach(allRequiredProperties::remove); - return allRequiredProperties; - }).orElseGet(LinkedHashMap::new); - } } diff --git a/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/InternalFeatureValidation.java b/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/InternalFeatureValidation.java new file mode 100644 index 0000000000..589625e862 --- /dev/null +++ b/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/InternalFeatureValidation.java @@ -0,0 +1,432 @@ +/* + * Copyright (c) 2024 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.wot.validation; + +import static org.eclipse.ditto.wot.validation.InternalValidation.determineDittoCategory; +import static org.eclipse.ditto.wot.validation.InternalValidation.enforceActionPayload; +import static org.eclipse.ditto.wot.validation.InternalValidation.enforceEventPayload; +import static org.eclipse.ditto.wot.validation.InternalValidation.ensureOnlyDefinedActions; +import static org.eclipse.ditto.wot.validation.InternalValidation.ensureOnlyDefinedEvents; +import static org.eclipse.ditto.wot.validation.InternalValidation.ensureOnlyDefinedProperties; +import static org.eclipse.ditto.wot.validation.InternalValidation.ensureRequiredProperties; +import static org.eclipse.ditto.wot.validation.InternalValidation.filterNonProvidedRequiredProperties; +import static org.eclipse.ditto.wot.validation.InternalValidation.success; +import static org.eclipse.ditto.wot.validation.InternalValidation.validateProperties; +import static org.eclipse.ditto.wot.validation.InternalValidation.validateProperty; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.json.JsonKey; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.Feature; +import org.eclipse.ditto.things.model.FeatureProperties; +import org.eclipse.ditto.things.model.Features; +import org.eclipse.ditto.things.model.Thing; +import org.eclipse.ditto.wot.model.DittoWotExtension; +import org.eclipse.ditto.wot.model.Properties; +import org.eclipse.ditto.wot.model.Property; +import org.eclipse.ditto.wot.model.SingleUriAtContext; +import org.eclipse.ditto.wot.model.ThingModel; + +final class InternalFeatureValidation { + + private InternalFeatureValidation() { + throw new AssertionError(); + } + + static CompletableFuture forbidNonModeledFeatures(@Nullable final Features features, + final Set definedFeatureIds, + final DittoHeaders dittoHeaders + ) { + + final Set extraFeatureIds = Optional.ofNullable(features) + .map(Features::stream) + .orElseGet(Stream::empty) + .map(Feature::getId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + extraFeatureIds.removeAll(definedFeatureIds); + if (!extraFeatureIds.isEmpty()) { + final var exceptionBuilder = WotThingModelPayloadValidationException + .newBuilder("Attempting to update the Thing with feature(s) are were not " + + "defined in the model: " + extraFeatureIds); + return CompletableFuture.failedFuture(exceptionBuilder + .dittoHeaders(dittoHeaders) + .build()); + } + return success(); + } + + static CompletableFuture enforcePresenceOfModeledFeatures(@Nullable final Features features, + final Set definedFeatureIds, + final DittoHeaders dittoHeaders + ) { + final Set existingFeatures = Optional.ofNullable(features) + .map(Features::stream) + .orElseGet(Stream::empty) + .map(Feature::getId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + if (!existingFeatures.containsAll(definedFeatureIds)) { + final Set missingFeatureIds = new LinkedHashSet<>(definedFeatureIds); + missingFeatureIds.removeAll(existingFeatures); + final var exceptionBuilder = WotThingModelPayloadValidationException + .newBuilder("Attempting to update the Thing with missing in the model " + + "defined features: " + missingFeatureIds); + return CompletableFuture.failedFuture(exceptionBuilder + .dittoHeaders(dittoHeaders) + .build()); + } + return success(); + } + + static CompletableFuture> enforceFeaturePropertiesInAllSubmodels( + final Map featureThingModels, + final Features features, + final boolean desiredProperties, + final boolean forbidNonModeledProperties, + final JsonPointer resourcePath, + final DittoHeaders dittoHeaders + ) { + final CompletableFuture> enforcedPropertiesListFuture; + final List> enforcedPropertiesFutures = featureThingModels + .entrySet() + .stream() + .filter(entry -> features.getFeature(entry.getKey()).isPresent()) + .map(entry -> + enforceFeatureProperties(entry.getValue(), + features.getFeature(entry.getKey()).orElseThrow(), + desiredProperties, + forbidNonModeledProperties, + resourcePath.append(Thing.JsonFields.FEATURES.getPointer()) + .addLeaf(JsonKey.of(entry.getKey())) + .append(desiredProperties ? + Feature.JsonFields.DESIRED_PROPERTIES.getPointer() : + Feature.JsonFields.PROPERTIES.getPointer() + ), + dittoHeaders + ) + ) + .toList(); + enforcedPropertiesListFuture = + CompletableFuture.allOf(enforcedPropertiesFutures.toArray(new CompletableFuture[0])) + .thenApply(ignored -> enforcedPropertiesFutures.stream() + .map(CompletableFuture::join) + .toList() + ); + return enforcedPropertiesListFuture; + } + + static CompletableFuture enforceFeatureProperties(final ThingModel featureThingModel, + final Feature feature, + final boolean desiredProperties, + final boolean forbidNonModeledProperties, + final JsonPointer resourcePath, + final DittoHeaders dittoHeaders + ) { + return featureThingModel.getProperties() + .map(tdProperties -> { + final FeatureProperties featureProperties; + if (desiredProperties) { + featureProperties = feature.getDesiredProperties() + .orElseGet(() -> FeatureProperties.newBuilder().build()); + } else { + featureProperties = feature.getProperties() + .orElseGet(() -> FeatureProperties.newBuilder().build()); + } + + final String containerNamePrefix = "Feature <" + feature.getId() + ">'s " + + (desiredProperties ? "desired " : ""); + final String containerNamePlural = containerNamePrefix + "properties"; + + final CompletableFuture ensureRequiredPropertiesStage; + if (!desiredProperties) { + ensureRequiredPropertiesStage = ensureRequiredProperties(featureThingModel, + tdProperties, + featureProperties, + containerNamePlural, + containerNamePrefix + "property", + resourcePath, + true, + dittoHeaders + ); + } else { + ensureRequiredPropertiesStage = success(); + } + + final CompletableFuture ensureOnlyDefinedPropertiesStage; + if (forbidNonModeledProperties) { + ensureOnlyDefinedPropertiesStage = ensureOnlyDefinedProperties(featureThingModel, + tdProperties, + featureProperties, + containerNamePlural, + true, + dittoHeaders + ); + } else { + ensureOnlyDefinedPropertiesStage = success(); + } + + final CompletableFuture validatePropertiesStage = validateProperties(featureThingModel, + tdProperties, + featureProperties, + !desiredProperties, + containerNamePlural, + resourcePath, + true, + dittoHeaders + ); + + return CompletableFuture.allOf( + ensureRequiredPropertiesStage, + ensureOnlyDefinedPropertiesStage, + validatePropertiesStage + ); + }).orElseGet(InternalValidation::success); + } + + static CompletableFuture enforceFeatureProperty(final ThingModel featureThingModel, + final String featureId, + final JsonPointer propertyPath, + final JsonValue propertyValue, + final boolean desiredProperty, + final boolean forbidNonModeledProperties, + final JsonPointer resourcePath, + final DittoHeaders dittoHeaders + ) { + // TODO TJ split?! + final Set categories = determineDittoCategories(featureThingModel, + featureThingModel.getProperties().orElse(Properties.of(Map.of())) + ); + final boolean isCategoryUpdate = propertyPath.getLevelCount() == 1 && + categories.contains(propertyPath.getRoot().orElseThrow().toString()); + + return featureThingModel.getProperties() + .map(tdProperties -> { + final String containerNamePrefix = "Feature <" + featureId + ">'s " + + (desiredProperty ? "desired " : ""); + final String containerNamePlural = containerNamePrefix + "properties"; + + final CompletableFuture ensureOnlyDefinedPropertiesStage; + if (isCategoryUpdate) { + final String dittoCategory = propertyPath.getRoot().orElseThrow().toString(); + if (forbidNonModeledProperties) { + final Properties propertiesInCategory = Properties.from(tdProperties.values().stream() + .filter(property -> + determineDittoCategory(featureThingModel, property) + .filter(cat -> cat.equals(dittoCategory)) + .isPresent() + ) + .toList()); + final FeatureProperties featureProperties = FeatureProperties.newBuilder() + .setAll(propertyValue.asObject()) + .build(); + ensureOnlyDefinedPropertiesStage = ensureOnlyDefinedProperties(featureThingModel, + propertiesInCategory, + featureProperties, + containerNamePlural, + false, + dittoHeaders + ); + } else { + ensureOnlyDefinedPropertiesStage = success(); + } + } else { + if (forbidNonModeledProperties) { + final FeatureProperties featureProperties = FeatureProperties.newBuilder() + .set(propertyPath, propertyValue) + .build(); + ensureOnlyDefinedPropertiesStage = ensureOnlyDefinedProperties(featureThingModel, + tdProperties, + featureProperties, + containerNamePlural, + true, + dittoHeaders + ); + } else { + ensureOnlyDefinedPropertiesStage = success(); + } + } + + final CompletableFuture validatePropertiesStage; + if (isCategoryUpdate) { + final String dittoCategory = propertyPath.getRoot().orElseThrow().toString(); + final List sameCategoryProperties = tdProperties.values().stream() + .filter(property -> + // gather all properties from the same category + determineDittoCategory(featureThingModel, property) + .filter(cat -> cat.equals(dittoCategory)) + .isPresent() + ) + .toList(); + + if (!sameCategoryProperties.isEmpty() && propertyValue.isObject()) { + validatePropertiesStage = validatePropertyCategory(featureThingModel, + Properties.from(sameCategoryProperties), + propertyPath, + !desiredProperty, + propertyValue.asObject(), + containerNamePrefix + "property", + resourcePath, + dittoHeaders + ); + } else { + validatePropertiesStage = validateProperty(featureThingModel, + tdProperties, + propertyPath, + !desiredProperty, + propertyValue, + containerNamePrefix + "property <" + propertyPath + ">", + resourcePath, + true, + dittoHeaders + ); + } + } else { + validatePropertiesStage = validateProperty(featureThingModel, + tdProperties, + propertyPath, + !desiredProperty, + propertyValue, + containerNamePrefix + "property <" + propertyPath + ">", + resourcePath, + true, + dittoHeaders + ); + } + + return CompletableFuture.allOf( + ensureOnlyDefinedPropertiesStage, + validatePropertiesStage + ); + }).orElseGet(InternalValidation::success); + } + + private static Set determineDittoCategories(final ThingModel thingModel, final Properties properties) { + final Optional dittoExtensionPrefix = thingModel.getAtContext() + .determinePrefixFor(SingleUriAtContext.DITTO_WOT_EXTENSION); + return dittoExtensionPrefix.stream().flatMap(prefix -> + properties.values().stream().flatMap(jsonFields -> + jsonFields.getValue(prefix + ":" + DittoWotExtension.DITTO_WOT_EXTENSION_CATEGORY) + .filter(JsonValue::isString) + .map(JsonValue::asString) + .stream() + ) + ).collect(Collectors.toSet()); + } + + static CompletableFuture enforceFeatureActionPayload(final String featureId, + final ThingModel featureThingModel, + final String messageSubject, + @Nullable final JsonValue inputPayload, + final boolean forbidNonModeledInboxMessages, + final JsonPointer resourcePath, + final boolean isInput, + final DittoHeaders dittoHeaders + ) { + final CompletableFuture firstStage; + if (forbidNonModeledInboxMessages) { + firstStage = ensureOnlyDefinedActions(featureThingModel.getActions().orElse(null), + messageSubject, + "Feature <" + featureId + ">'s", + dittoHeaders + ); + } else { + firstStage = success(); + } + return firstStage.thenCompose(unused -> + enforceActionPayload(featureThingModel, messageSubject, inputPayload, resourcePath, isInput, + "Feature <" + featureId + ">'s action <" + messageSubject + "> " + + (isInput ? "input" : "output"), + dittoHeaders + ) + ); + } + + static CompletableFuture enforceFeatureEventPayload(final String featureId, + final ThingModel featureThingModel, + final String messageSubject, + @Nullable final JsonValue payload, + final boolean forbidNonModeledOutboxMessages, + final JsonPointer resourcePath, + final DittoHeaders dittoHeaders + ) { + final CompletableFuture firstStage; + if (forbidNonModeledOutboxMessages) { + firstStage = ensureOnlyDefinedEvents(dittoHeaders, + featureThingModel.getEvents().orElse(null), + messageSubject, + "Feature <" + featureId + ">'s" + ); + } else { + firstStage = success(); + } + return firstStage.thenCompose(unused -> + enforceEventPayload(featureThingModel, messageSubject, payload, resourcePath, + "Feature <" + featureId + ">'s event <" + messageSubject + "> data", + dittoHeaders + ) + ); + } + + static CompletableFuture validatePropertyCategory(final ThingModel featureThingModel, + final Properties categoryProperties, + final JsonPointer propertyPath, + final boolean validateRequiredObjectFields, + final JsonObject categoryObject, + final String propertyDescription, + final JsonPointer resourcePath, + final DittoHeaders dittoHeaders + ) { + final Map nonProvidedRequiredProperties = + filterNonProvidedRequiredProperties(categoryProperties, featureThingModel, categoryObject, false); + final JsonKey category = propertyPath.getRoot().orElseThrow(); + final String propertyCategoryDescription = propertyDescription + " category <" + category + ">"; + if (validateRequiredObjectFields && !nonProvidedRequiredProperties.isEmpty()) { + final var exceptionBuilder = WotThingModelPayloadValidationException + .newBuilder("Required JSON fields were missing from the " + propertyCategoryDescription); + nonProvidedRequiredProperties.forEach((rpKey, requiredProperty) -> + exceptionBuilder.addValidationDetail( + propertyPath.addLeaf(JsonKey.of(rpKey)), + List.of(propertyDescription + " category <" + category + + ">'s <" + rpKey + "> is non optional and must be provided") + ) + ); + return CompletableFuture + .failedFuture(exceptionBuilder.dittoHeaders(dittoHeaders).build()); + } + + return validateProperties( + featureThingModel, + categoryProperties, + categoryObject, + validateRequiredObjectFields, + propertyCategoryDescription, + resourcePath, + false, + dittoHeaders + ); + } + +} diff --git a/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/InternalThingValidation.java b/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/InternalThingValidation.java new file mode 100644 index 0000000000..5d9fc47f44 --- /dev/null +++ b/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/InternalThingValidation.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2024 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.wot.validation; + +import static org.eclipse.ditto.wot.validation.InternalValidation.enforceActionPayload; +import static org.eclipse.ditto.wot.validation.InternalValidation.enforceEventPayload; +import static org.eclipse.ditto.wot.validation.InternalValidation.ensureOnlyDefinedActions; +import static org.eclipse.ditto.wot.validation.InternalValidation.ensureOnlyDefinedEvents; +import static org.eclipse.ditto.wot.validation.InternalValidation.ensureOnlyDefinedProperties; +import static org.eclipse.ditto.wot.validation.InternalValidation.ensureRequiredProperties; +import static org.eclipse.ditto.wot.validation.InternalValidation.success; +import static org.eclipse.ditto.wot.validation.InternalValidation.validateProperties; +import static org.eclipse.ditto.wot.validation.InternalValidation.validateProperty; + +import java.util.concurrent.CompletableFuture; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.Attributes; +import org.eclipse.ditto.wot.model.ThingModel; + +final class InternalThingValidation { + + private InternalThingValidation() { + throw new AssertionError(); + } + + static CompletableFuture enforceThingAttributes(final ThingModel thingModel, + final Attributes attributes, + final boolean forbidNonModeledAttributes, + final JsonPointer resourcePath, + final DittoHeaders dittoHeaders + ) { + return thingModel.getProperties() + .map(tdProperties -> { + final String containerNamePlural = "Thing's attributes"; + final CompletableFuture ensureRequiredPropertiesStage = ensureRequiredProperties(thingModel, + tdProperties, + attributes, + containerNamePlural, + "Thing's attribute", + resourcePath, + false, + dittoHeaders + ); + + final CompletableFuture ensureOnlyDefinedPropertiesStage; + if (forbidNonModeledAttributes) { + ensureOnlyDefinedPropertiesStage = ensureOnlyDefinedProperties(thingModel, + tdProperties, + attributes, + containerNamePlural, + false, + dittoHeaders + ); + } else { + ensureOnlyDefinedPropertiesStage = success(); + } + + final CompletableFuture validatePropertiesStage = validateProperties(thingModel, + tdProperties, + attributes, + true, + containerNamePlural, + resourcePath, + false, + dittoHeaders + ); + + return CompletableFuture.allOf( + ensureRequiredPropertiesStage, + ensureOnlyDefinedPropertiesStage, + validatePropertiesStage + ); + }).orElseGet(InternalValidation::success); + } + + static CompletableFuture enforceThingAttribute(final ThingModel thingModel, + final JsonPointer attributePath, + final JsonValue attributeValue, + final boolean forbidNonModeledAttributes, + final JsonPointer resourcePath, + final DittoHeaders dittoHeaders + ) { + + return thingModel.getProperties() + .map(tdProperties -> { + final Attributes attributes = Attributes.newBuilder().set(attributePath, attributeValue).build(); + final CompletableFuture ensureOnlyDefinedPropertiesStage; + if (forbidNonModeledAttributes) { + ensureOnlyDefinedPropertiesStage = ensureOnlyDefinedProperties(thingModel, + tdProperties, + attributes, + "Thing's attributes", + false, + dittoHeaders + ); + } else { + ensureOnlyDefinedPropertiesStage = success(); + } + + final CompletableFuture validatePropertiesStage = validateProperty(thingModel, + tdProperties, + attributePath, + true, + attributeValue, + "Thing's attribute <" + attributePath + ">", resourcePath, + false, + dittoHeaders + ); + + return CompletableFuture.allOf( + ensureOnlyDefinedPropertiesStage, + validatePropertiesStage + ); + }).orElseGet(InternalValidation::success); + } + + static CompletableFuture enforceThingActionPayload(final ThingModel thingModel, + final String messageSubject, + @Nullable final JsonValue payload, + final boolean forbidNonModeledInboxMessages, + final JsonPointer resourcePath, + final boolean isInput, + final DittoHeaders dittoHeaders + ) { + final CompletableFuture firstStage; + if (isInput && forbidNonModeledInboxMessages) { + firstStage = ensureOnlyDefinedActions(thingModel.getActions().orElse(null), + messageSubject, + "Thing's", + dittoHeaders + ); + } else { + firstStage = success(); + } + return firstStage.thenCompose(unused -> + enforceActionPayload(thingModel, messageSubject, payload, resourcePath, isInput, + "Thing's action <" + messageSubject + "> " + (isInput ? "input" : "output"), + dittoHeaders + ) + ); + } + + static CompletableFuture enforceThingEventPayload(final ThingModel thingModel, + final String messageSubject, + @Nullable final JsonValue payload, + final boolean forbidNonModeledOutboxMessages, + final JsonPointer resourcePath, + final DittoHeaders dittoHeaders + ) { + final CompletableFuture firstStage; + if (forbidNonModeledOutboxMessages) { + firstStage = ensureOnlyDefinedEvents(dittoHeaders, + thingModel.getEvents().orElse(null), messageSubject, "Thing's"); + } else { + firstStage = success(); + } + return firstStage.thenCompose(unused -> + enforceEventPayload(thingModel, messageSubject, payload, resourcePath, + "Thing's event <" + messageSubject + "> data", + dittoHeaders + ) + ); + } + +} diff --git a/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/InternalValidation.java b/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/InternalValidation.java new file mode 100644 index 0000000000..ea7b67db81 --- /dev/null +++ b/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/InternalValidation.java @@ -0,0 +1,443 @@ +/* + * Copyright (c) 2024 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.wot.validation; + +import java.util.AbstractMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.json.JsonKey; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.wot.model.Actions; +import org.eclipse.ditto.wot.model.DittoWotExtension; +import org.eclipse.ditto.wot.model.Event; +import org.eclipse.ditto.wot.model.Events; +import org.eclipse.ditto.wot.model.Properties; +import org.eclipse.ditto.wot.model.Property; +import org.eclipse.ditto.wot.model.SingleDataSchema; +import org.eclipse.ditto.wot.model.SingleUriAtContext; +import org.eclipse.ditto.wot.model.ThingModel; +import org.eclipse.ditto.wot.model.TmOptionalElement; + +import com.networknt.schema.output.OutputUnit; + +final class InternalValidation { + + private static final JsonSchemaTools JSON_SCHEMA_TOOLS = new JsonSchemaTools(); + + private InternalValidation() { + throw new AssertionError(); + } + + static CompletableFuture ensureOnlyDefinedProperties(final ThingModel thingModel, + final Properties tdProperties, + final JsonObject propertiesContainer, + final String containerNamePlural, + final boolean handleDittoCategory, + final DittoHeaders dittoHeaders + ) { + final Set allDefinedPropertyKeys = tdProperties.keySet(); + final Set allAvailablePropertiesKeys = + propertiesContainer.getKeys().stream().map(JsonKey::toString) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (handleDittoCategory) { + tdProperties.forEach((propertyName, property) -> { + final Optional dittoCategory = determineDittoCategory(thingModel, property); + final String categorizedPropertyName = dittoCategory + .map(c -> c + "/").orElse("") + .concat(propertyName); + if (propertiesContainer.contains(JsonPointer.of(categorizedPropertyName))) { + allAvailablePropertiesKeys.remove(propertyName); + dittoCategory.ifPresent(allAvailablePropertiesKeys::remove); + } + }); + } else { + allAvailablePropertiesKeys.removeAll(allDefinedPropertyKeys); + } + + if (!allAvailablePropertiesKeys.isEmpty()) { + final var exceptionBuilder = WotThingModelPayloadValidationException + .newBuilder("The " + containerNamePlural + " contained " + + "JSON fields which were not defined in the model: " + allAvailablePropertiesKeys); + return CompletableFuture.failedFuture(exceptionBuilder + .dittoHeaders(dittoHeaders) + .build()); + } + return success(); + } + + static CompletableFuture ensureRequiredProperties(final ThingModel thingModel, + final Properties tdProperties, + final JsonObject propertiesContainer, + final String containerNamePlural, + final String containerName, + final JsonPointer resourcePath, + final boolean handleDittoCategory, + final DittoHeaders dittoHeaders + ) { + final Map nonProvidedRequiredProperties = + filterNonProvidedRequiredProperties(tdProperties, thingModel, propertiesContainer, handleDittoCategory); + + if (!nonProvidedRequiredProperties.isEmpty()) { + final var exceptionBuilder = WotThingModelPayloadValidationException + .newBuilder("Required JSON fields were missing from the " + containerNamePlural); + nonProvidedRequiredProperties.forEach((rpKey, requiredProperty) -> + { + JsonPointer fullPointer = resourcePath; + final Optional dittoCategory = determineDittoCategory(thingModel, requiredProperty); + if (handleDittoCategory && dittoCategory.isPresent()) { + fullPointer = fullPointer.addLeaf(JsonKey.of(dittoCategory.get())); + } + fullPointer = fullPointer.addLeaf(JsonKey.of(rpKey)); + exceptionBuilder.addValidationDetail( + fullPointer, + List.of(containerName + " <" + rpKey + "> is non optional and must be provided") + ); + } + ); + return CompletableFuture.failedFuture(exceptionBuilder.dittoHeaders(dittoHeaders).build()); + } + return success(); + } + + static Map filterNonProvidedRequiredProperties(final Properties tdProperties, + final ThingModel thingModel, + final JsonObject propertiesContainer, + final boolean handleDittoCategory + ) { + final Map requiredProperties = extractRequiredProperties(tdProperties, thingModel); + final Map nonProvidedRequiredProperties = new LinkedHashMap<>(requiredProperties); + if (handleDittoCategory) { + requiredProperties.forEach((rpKey, requiredProperty) -> { + final Optional dittoCategory = determineDittoCategory(thingModel, requiredProperty); + if (dittoCategory.isPresent()) { + propertiesContainer.getValue(dittoCategory.get()) + .filter(JsonValue::isObject) + .map(JsonValue::asObject) + .ifPresent(categorizedProperties -> categorizedProperties.getKeys().stream() + .map(JsonKey::toString) + .forEach(nonProvidedRequiredProperties::remove) + ); + } else { + propertiesContainer.getKeys().stream() + .map(JsonKey::toString) + .forEach(nonProvidedRequiredProperties::remove); + } + }); + } else { + propertiesContainer.getKeys().stream() + .map(JsonKey::toString) + .forEach(nonProvidedRequiredProperties::remove); + } + return nonProvidedRequiredProperties; + } + + private static Map extractRequiredProperties(final Properties tdProperties, + final ThingModel thingModel + ) { + return thingModel.getTmOptional().map(tmOptionalElements -> { + final Map allRequiredProperties = new LinkedHashMap<>(tdProperties); + tmOptionalElements.stream() + .map(TmOptionalElement::toString) + .filter(el -> el.startsWith("/properties/")) + .map(el -> el.replace("/properties/", "")) + .forEach(allRequiredProperties::remove); + return allRequiredProperties; + }).orElseGet(LinkedHashMap::new); + } + + static CompletableFuture ensureOnlyDefinedActions(@Nullable final Actions actions, + final String messageSubject, + final String containerName, + final DittoHeaders dittoHeaders + ) { + final Set allDefinedActionKeys = Optional.ofNullable(actions).map(Actions::keySet).orElseGet(Set::of); + final boolean messageSubjectIsDefinedAsAction = allDefinedActionKeys.contains(messageSubject); + if (!messageSubjectIsDefinedAsAction) { + final var exceptionBuilder = WotThingModelPayloadValidationException + .newBuilder("The " + containerName + " message subject <" + + messageSubject + "> is not defined as known action in the model: " + allDefinedActionKeys + ); + return CompletableFuture.failedFuture(exceptionBuilder + .dittoHeaders(dittoHeaders) + .build()); + } + return success(); + } + + static CompletableFuture ensureOnlyDefinedEvents(final DittoHeaders dittoHeaders, + @Nullable final Events events, + final String messageSubject, + final String containerName + ) { + final Set allDefinedEventKeys = Optional.ofNullable(events).map(Events::keySet).orElseGet(Set::of); + final boolean messageSubjectIsDefinedAsEvent = allDefinedEventKeys.contains(messageSubject); + if (!messageSubjectIsDefinedAsEvent) { + final var exceptionBuilder = WotThingModelPayloadValidationException + .newBuilder("The " + containerName + " message subject <" + + messageSubject + "> is not defined as known event in the model: " + allDefinedEventKeys + ); + return CompletableFuture.failedFuture(exceptionBuilder + .dittoHeaders(dittoHeaders) + .build()); + } + return success(); + } + + static Optional determineDittoCategory(final ThingModel thingModel, final Property property) { + final Optional dittoExtensionPrefix = thingModel.getAtContext() + .determinePrefixFor(SingleUriAtContext.DITTO_WOT_EXTENSION); + return dittoExtensionPrefix.flatMap(prefix -> + property.getValue(prefix + ":" + DittoWotExtension.DITTO_WOT_EXTENSION_CATEGORY) + ) + .filter(JsonValue::isString) + .map(JsonValue::asString); + } + + static CompletableFuture validateProperties(final ThingModel thingModel, + final Properties tdProperties, + final JsonObject propertiesContainer, + final boolean validateRequiredObjectFields, + final String containerNamePlural, + final JsonPointer resourcePath, + final boolean handleDittoCategory, + final DittoHeaders dittoHeaders + ) { + final Map invalidProperties; + if (handleDittoCategory) { + invalidProperties = determineInvalidProperties(tdProperties, + p -> propertiesContainer.getValue( + determineDittoCategory(thingModel, p) + .map(c -> c + "/") + .orElse("") + .concat(p.getPropertyName()) + ), + validateRequiredObjectFields, + dittoHeaders + ); + } else { + invalidProperties = determineInvalidProperties(tdProperties, + p -> propertiesContainer.getValue(p.getPropertyName()), + validateRequiredObjectFields, + dittoHeaders + ); + } + + if (!invalidProperties.isEmpty()) { + final var exceptionBuilder = WotThingModelPayloadValidationException + .newBuilder("The " + containerNamePlural + " contained validation errors, " + + "check the validation details."); + invalidProperties.forEach((property, validationOutputUnit) -> { + JsonPointer fullPointer = resourcePath; + final Optional dittoCategory = determineDittoCategory(thingModel, property); + if (handleDittoCategory && dittoCategory.isPresent()) { + fullPointer = fullPointer.addLeaf(JsonKey.of(dittoCategory.get())); + } + fullPointer = fullPointer.addLeaf(JsonKey.of(property.getPropertyName())); + exceptionBuilder.addValidationDetail( + fullPointer, + validationOutputUnit.getDetails().stream() + .map(ou -> ou.getInstanceLocation() + ": " + ou.getErrors()) + .toList() + ); + }); + return CompletableFuture.failedFuture(exceptionBuilder + .dittoHeaders(dittoHeaders) + .build()); + } + return success(); + } + + static Map determineInvalidProperties(final Properties tdProperties, + final Function> propertyExtractor, + final boolean validateRequiredObjectFields, + final DittoHeaders dittoHeaders + ) { + return tdProperties.entrySet().stream() + .flatMap(tdPropertyEntry -> + propertyExtractor.apply(tdPropertyEntry.getValue()) + .map(propertyValue -> new AbstractMap.SimpleEntry<>( + tdPropertyEntry.getValue(), + JSON_SCHEMA_TOOLS.validateDittoJsonBasedOnDataSchema( + tdPropertyEntry.getValue(), + JsonPointer.empty(), + validateRequiredObjectFields, + propertyValue, + dittoHeaders + ) + )) + .filter(entry -> !entry.getValue().isValid()) + .stream() + ).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (u, v) -> { + throw new IllegalStateException(String.format("Duplicate key %s", u)); + }, LinkedHashMap::new)); + } + + static CompletableFuture validateProperty(final ThingModel thingModel, + final Properties tdProperties, + final JsonPointer propertyPath, + final boolean validateRequiredObjectFields, + final JsonValue propertyValue, + final String propertyDescription, + final JsonPointer resourcePath, + final boolean handleDittoCategory, + final DittoHeaders dittoHeaders + ) { + final JsonValue valueToValidate; + if (propertyPath.getLevelCount() > 1) { + valueToValidate = JsonObject.newBuilder() + .set(propertyPath.getSubPointer(1).orElseThrow(), propertyValue) + .build(); + } else { + valueToValidate = propertyValue; + } + + return tdProperties.values() + .stream() + .filter(property -> { + if (handleDittoCategory) { + final JsonPointer thePropertyPath = determineDittoCategory(thingModel, property) + .flatMap(cat -> propertyPath.getSubPointer(1)) + .orElse(propertyPath); + return property.getPropertyName().equals(thePropertyPath.getRoot().orElseThrow().toString()); + } else { + return property.getPropertyName().equals(propertyPath.getRoot().orElseThrow().toString()); + } + }) + .findFirst() + .map(property -> { + if (handleDittoCategory) { + final Optional dittoCategory = determineDittoCategory(thingModel, property); + final JsonPointer thePropertyPath = dittoCategory + .flatMap(cat -> propertyPath.getSubPointer(1)) + .orElse(propertyPath); + final JsonValue theValueToValidate = dittoCategory + .flatMap(cat -> valueToValidate.asObject().getValue(thePropertyPath)) + .orElse(valueToValidate); + return validateSingleDataSchema( + property, + propertyDescription, + thePropertyPath, + validateRequiredObjectFields, + theValueToValidate, + resourcePath, + dittoHeaders + ); + } else { + return validateSingleDataSchema( + property, + propertyDescription, + propertyPath, + validateRequiredObjectFields, + valueToValidate, + resourcePath, + dittoHeaders + ); + } + }).orElseGet(InternalValidation::success); + } + + static CompletableFuture enforceActionPayload(final ThingModel thingModel, + final String messageSubject, + @Nullable final JsonValue inputPayload, + final JsonPointer resourcePath, + final boolean isInput, + final String validationFailedDescription, + final DittoHeaders dittoHeaders + ) { + return thingModel.getActions() + .flatMap(action -> action.getAction(messageSubject)) + .flatMap(action -> isInput ? action.getInput() : action.getOutput()) + .map(schema -> validateSingleDataSchema( + schema, + validationFailedDescription, + JsonPointer.empty(), + true, + inputPayload, + resourcePath, + dittoHeaders + )) + .orElseGet(InternalValidation::success); + } + + static CompletableFuture enforceEventPayload(final ThingModel thingModel, + final String messageSubject, + @Nullable final JsonValue dataPayload, + final JsonPointer resourcePath, + final String validationFailedDescription, + final DittoHeaders dittoHeaders + ) { + return thingModel.getEvents() + .flatMap(event -> event.getEvent(messageSubject)) + .flatMap(Event::getData) + .map(schema -> validateSingleDataSchema( + schema, + validationFailedDescription, + JsonPointer.empty(), + true, + dataPayload, + resourcePath, + dittoHeaders + )) + .orElseGet(InternalValidation::success); + } + + static CompletableFuture validateSingleDataSchema(final SingleDataSchema dataSchema, + final String validatedDescription, + final JsonPointer pointerPath, + final boolean validateRequiredObjectFields, + @Nullable final JsonValue jsonValue, + final JsonPointer resourcePath, + final DittoHeaders dittoHeaders + ) { + final OutputUnit validationOutput = JSON_SCHEMA_TOOLS.validateDittoJsonBasedOnDataSchema( + dataSchema, + pointerPath, + validateRequiredObjectFields, + jsonValue, + dittoHeaders + ); + + if (!validationOutput.isValid()) { + final var exceptionBuilder = WotThingModelPayloadValidationException + .newBuilder("The " + validatedDescription + " contained validation errors, " + + "check the validation details."); + exceptionBuilder.addValidationDetail( + resourcePath, + validationOutput.getDetails().stream() + .map(ou -> ou.getInstanceLocation() + ": " + ou.getErrors()) + .toList() + ); + return CompletableFuture.failedFuture(exceptionBuilder + .dittoHeaders(dittoHeaders) + .build()); + } + return success(); + } + + static CompletableFuture success() { + return CompletableFuture.completedFuture(null); + } +} diff --git a/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/config/package-info.java b/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/config/package-info.java new file mode 100644 index 0000000000..78613f6691 --- /dev/null +++ b/wot/validation/src/main/java/org/eclipse/ditto/wot/validation/config/package-info.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +/** + * @since 3.6.0 + */ +@org.eclipse.ditto.utils.jsr305.annotations.AllParametersAndReturnValuesAreNonnullByDefault +package org.eclipse.ditto.wot.validation.config; \ No newline at end of file