From 5d5b2f87656773e279af5b3b4565a98e5dba812d Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Mon, 27 Jan 2025 20:01:45 +0100 Subject: [PATCH 01/15] add new definition migration endpoint --- .../main/resources/openapi/ditto-api-2.yml | 137 ++++++++ .../resources/openapi/sources/api-2-index.yml | 2 + .../openapi/sources/paths/things/thing.yml | 78 ++++- .../things/migrateThingDefinitionRequest.yml | 65 ++++ .../resources/pages/ditto/httpapi-concepts.md | 33 ++ .../endpoints/routes/things/ThingsRoute.java | 36 +- .../routes/things/ThingsRouteTest.java | 38 +++ .../modify/MigrateThingDefinition.java | 312 ++++++++++++++++++ .../modify/MigrateThingDefinitionTest.java | 192 +++++++++++ .../MigrateThingDefinitionStrategy.java | 279 ++++++++++++++++ .../commands/ThingCommandStrategies.java | 1 + 11 files changed, 1171 insertions(+), 2 deletions(-) create mode 100644 documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionRequest.yml create mode 100644 things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java create mode 100644 things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java create mode 100644 things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java diff --git a/documentation/src/main/resources/openapi/ditto-api-2.yml b/documentation/src/main/resources/openapi/ditto-api-2.yml index 7378b48199..f2b714eeda 100644 --- a/documentation/src/main/resources/openapi/ditto-api-2.yml +++ b/documentation/src/main/resources/openapi/ditto-api-2.yml @@ -987,6 +987,92 @@ paths: * a string: `"value"` - Currently the definition should follow the pattern: [_a-zA-Z0-9\-]:[_a-zA-Z0-9\-]:[_a-zA-Z0-9\-] * an empty string: `""` * `null`: the definition will be deleted + /api/2/things/{thingId}/migrateDefinition: + put: + summary: Migrate Thing Definition + description: |- + Migrate the definition of a Thing with the given `thingId`. The update includes a new definition URL and optional migration payload. + + The operation will merge the provided data into the existing thing. If `initializeProperties` is set to `true`, missing properties will be initialized. + + **Example usage:** + ```json + { + "thingDefinitionUrl": "https://models.example.com/thing-definition-1.0.0.tm.jsonld", + "migrationPayload": { + "attributes": { + "manufacturer": "New Corp", + "location": "Berlin, main floor" + }, + "features": { + "thermostat": { + "properties": { + "status": { + "temperature": { + "value": 23.5, + "unit": "DEGREE_CELSIUS" + } + } + } + } + } + }, + "patchConditions": { + "thing:/features/thermostat": "not(exists(/features/thermostat))" + }, + "initializeMissingPropertiesFromDefaults": true + } + ``` + tags: + - Things + parameters: + - name: thingId + in: path + required: true + description: The unique identifier of the Thing to update. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MigrateThingDefinitionRequest' + responses: + '204': + description: The thing definition was successfully updated. + '400': + description: |- + The request could not be completed. Possible reasons: + * Invalid JSON request body. + * Missing or incorrect values in the request. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + '401': + description: The request could not be completed due to missing authentication. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + '403': + description: The caller does not have permission to update the thing definition. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + '404': + description: The thing with the specified ID was not found. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + '412': + $ref: '#/components/responses/PreconditionFailed' + '424': + $ref: '#/components/responses/DependencyFailed' + delete: summary: Delete the definition of a specific thing description: Deletes the definition of the thing identified by the `thingId`. @@ -9273,6 +9359,57 @@ components: required: - thingId - policyId + MigrateThingDefinitionRequest: + type: object + description: JSON payload to update the definition of a Thing. + properties: + thingDefinitionUrl: + type: string + format: uri + description: "The URL of the new Thing definition to be applied." + example: "https://models.example.com/thing-definition-1.0.0.tm.jsonld" + migrationPayload: + type: object + description: "Optional migration payload with updates to attributes and features." + properties: + attributes: + type: object + additionalProperties: true + description: "Attributes to be updated in the thing." + example: + manufacturer: "New Corp" + location: "Berlin, main floor" + features: + type: object + additionalProperties: + type: object + properties: + properties: + type: object + additionalProperties: true + description: "Features to be updated in the thing." + example: + thermostat: + properties: + status: + temperature: + value: 23.5 + unit: "DEGREE_CELSIUS" + patchConditions: + type: object + description: "Optional conditions to apply the migration only if the existing thing matches the specified values." + additionalProperties: + type: string + example: + thing:/features/thermostat: "not(exists(/features/thermostat))" + initializeMissingPropertiesFromDefaults: + type: boolean + description: "Flag indicating whether missing properties should be initialized with default values." + example: true + required: + - thingId + - thingDefinitionUrl + - migrationPayload NewPolicy: type: object description: Policy consisting of policy entries diff --git a/documentation/src/main/resources/openapi/sources/api-2-index.yml b/documentation/src/main/resources/openapi/sources/api-2-index.yml index a41f08be17..c599585458 100644 --- a/documentation/src/main/resources/openapi/sources/api-2-index.yml +++ b/documentation/src/main/resources/openapi/sources/api-2-index.yml @@ -60,6 +60,8 @@ paths: $ref: "./paths/things/index.yml" '/api/2/things/{thingId}': $ref: "./paths/things/thing.yml" + '/api/2/things/{thingId}/MigrateDefinition': + $ref: "./paths/things/thing.yml" '/api/2/things/{thingId}/definition': $ref: "./paths/things/definition.yml" '/api/2/things/{thingId}/policyId': diff --git a/documentation/src/main/resources/openapi/sources/paths/things/thing.yml b/documentation/src/main/resources/openapi/sources/paths/things/thing.yml index 25c7cb290d..5efc3f567c 100644 --- a/documentation/src/main/resources/openapi/sources/paths/things/thing.yml +++ b/documentation/src/main/resources/openapi/sources/paths/things/thing.yml @@ -1,4 +1,4 @@ -# Copyright (c) 2020 Contributors to the Eclipse Foundation +# Copyright (c) 2025 Contributors to the Eclipse Foundation # # See the NOTICE file(s) distributed with this work for additional # information regarding copyright ownership. @@ -502,6 +502,82 @@ patch: smartMode: null tempToHold: 50 description: JSON representation of the thing to be patched. +/post: + summary: Update the definition of an existing Thing + description: |- + Updates the definition of the specified thing by providing a new definition URL along with an optional migration payload. + + The request body allows specifying: + - A new Thing definition URL. + - A migration payload containing updates to attributes and features. + - Patch conditions to ensure consistent updates. + - Whether properties should be initialized if missing. + + ### Example: + ```json + { + "thingDefinitionUrl": "https://example.com/new-thing-definition.json", + "migrationPayload": { + "attributes": { + "manufacturer": "New Corp" + }, + "features": { + "sensor": { + "properties": { + "status": { + "temperature": { + "value": 25.0 + } + } + } + } + } + }, + "patchConditions": { + "thing:/features/sensor": "not(exists(/features/sensor))" + }, + "initializeMissingPropertiesFromDefaults": true + } + ``` + + tags: + - Things + parameters: + - $ref: '../../parameters/thingIdPathParam.yml' + requestBody: + description: JSON payload containing the new definition URL, migration payload, patch conditions, and initialization flag. + required: true + content: + application/json: + schema: + $ref: '../../schemas/things/migrateThingDefinitionRequest.yml' + responses: + '204': + description: The thing definition was successfully updated. No content is returned. + '400': + description: The request could not be processed due to invalid input. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '401': + description: Unauthorized request due to missing authentication. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '404': + description: The specified thing could not be found. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '412': + description: The update conditions were not met. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' delete: summary: Delete a specific thing description: |- diff --git a/documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionRequest.yml b/documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionRequest.yml new file mode 100644 index 0000000000..6bffa3d3b3 --- /dev/null +++ b/documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionRequest.yml @@ -0,0 +1,65 @@ +# Copyright (c) 2025 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 +type: object +description: JSON payload to migrate the definition of a Thing. + +properties: + thingDefinitionUrl: + type: string + format: uri + description: "The URL of the new Thing definition to be applied." + example: "https://models.example.com/thing-definition-1.0.0.tm.jsonld" + + migrationPayload: + type: object + description: "Optional migration payload with updates to attributes and features." + properties: + attributes: + type: object + additionalProperties: true + description: "Attributes to be updated in the thing." + example: + manufacturer: "New Corp" + location: "Berlin, main floor" + features: + type: object + additionalProperties: + type: object + properties: + properties: + type: object + additionalProperties: true + description: "Features to be updated in the thing." + example: + thermostat: + properties: + status: + temperature: + value: 23.5 + unit: "DEGREE_CELSIUS" + + patchConditions: + type: object + description: "Optional conditions to apply the migration only if the existing thing matches the specified values." + additionalProperties: + type: string + example: + thing:/features/thermostat: "not(exists(/features/thermostat))" + + initializeMissingPropertiesFromDefaults: + type: boolean + description: "Flag indicating whether missing properties should be initialized with default values." + example: true + +required: + - thingId + - thingDefinitionUrl + - migrationPayload diff --git a/documentation/src/main/resources/pages/ditto/httpapi-concepts.md b/documentation/src/main/resources/pages/ditto/httpapi-concepts.md index 81682f7d28..0853133697 100644 --- a/documentation/src/main/resources/pages/ditto/httpapi-concepts.md +++ b/documentation/src/main/resources/pages/ditto/httpapi-concepts.md @@ -106,6 +106,39 @@ The following additional API endpoints are automatically available: * `/things/{thingId}/features/lamp/properties/color`: accessing the `color` properties of the feature `lamp` of the specific thing +#### `/things` in API 2 - update-definition +Migrate Thing Definitions +The endpoint `/things/{thingId}/migrateDefinition`allows migrating the thing definition with a new model, as well as optionally migrating attributes and features. + +HTTP Method: POST /things/{thingId}/migrateDefinition +Request Example +```json +{ + "thingDefinitionUrl": "https://models.example.com/thing-definition-1.0.0.tm.jsonld", + "migrationPayload": { + "attributes": { + "manufacturer": "New Corp", + "location": "Berlin, main floor" + }, + "features": { + "thermostat": { + "properties": { + "status": { + "temperature": { + "value": 23.5, + "unit": "DEGREE_CELSIUS" + } + } + } + } + } + }, + "patchConditions": { + "thing:/features/thermostat": "not(exists(/features/thermostat))" + }, + "initializeMissingPropertiesFromDefaults": true +} +``` #### `/policies` in API 2 The base endpoint for accessing and working with `Policies`.
diff --git a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java index e40d2eeb59..53ae7bfe36 100755 --- a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java +++ b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017 Contributors to the Eclipse Foundation + * Copyright (c) 2025 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -12,6 +12,7 @@ */ package org.eclipse.ditto.gateway.service.endpoints.routes.things; +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; import static org.eclipse.ditto.base.model.exceptions.DittoJsonException.wrapJsonRuntimeException; import java.util.Arrays; @@ -56,6 +57,7 @@ import org.eclipse.ditto.things.model.ThingDefinition; import org.eclipse.ditto.things.model.ThingId; import org.eclipse.ditto.things.model.ThingsModelFactory; +import org.eclipse.ditto.things.model.signals.commands.ThingCommand; import org.eclipse.ditto.things.model.signals.commands.exceptions.PolicyIdNotDeletableException; import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingIdNotExplicitlySettableException; import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingMergeInvalidException; @@ -71,6 +73,7 @@ import org.eclipse.ditto.things.model.signals.commands.modify.ModifyPolicyId; import org.eclipse.ditto.things.model.signals.commands.modify.ModifyThing; import org.eclipse.ditto.things.model.signals.commands.modify.ModifyThingDefinition; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; import org.eclipse.ditto.things.model.signals.commands.query.RetrieveAttribute; import org.eclipse.ditto.things.model.signals.commands.query.RetrieveAttributes; import org.eclipse.ditto.things.model.signals.commands.query.RetrievePolicyId; @@ -90,6 +93,7 @@ public final class ThingsRoute extends AbstractRoute { private static final String PATH_ATTRIBUTES = "attributes"; private static final String PATH_THING_DEFINITION = "definition"; private static final String NAMESPACE_PARAMETER = "namespace"; + private static final String PATH_MIGRATE_DEFINITION = "migrateDefinition"; private final FeaturesRoute featuresRoute; private final MessagesRoute messagesRoute; @@ -153,6 +157,7 @@ private Route buildThingEntryRoute(final RequestContext ctx, thingsEntryAttributes(ctx, dittoHeaders, thingId), thingsEntryAttributesEntry(ctx, dittoHeaders, thingId), thingsEntryDefinition(ctx, dittoHeaders, thingId), + thingsEntryMigrateDefinition(ctx, dittoHeaders, thingId), thingsEntryFeatures(ctx, dittoHeaders, thingId), thingsEntryInboxOutbox(ctx, dittoHeaders, thingId) ); @@ -575,6 +580,35 @@ private Route thingsEntryDefinition(final RequestContext ctx, final DittoHeaders ); } + private Route thingsEntryMigrateDefinition(final RequestContext ctx, + final DittoHeaders dittoHeaders, + final ThingId thingId) { + return rawPathPrefix(PathMatchers.slash().concat(PATH_MIGRATE_DEFINITION), () -> + pathEndOrSingleSlash(() -> + // POST /things//migrateDefinition + post(() -> ensureMediaTypeJsonWithFallbacksThenExtractDataBytes(ctx, dittoHeaders, + payloadSource -> handlePerRequest(ctx, dittoHeaders, payloadSource, + payload -> { + final JsonObject inputJson = + wrapJsonRuntimeException(() -> JsonFactory.newObject(payload)); + final JsonObject updatedJson = addThingId(inputJson, thingId); + + final MigrateThingDefinition migrateThingDefinitionCommand = + MigrateThingDefinition.withThing(thingId, updatedJson, dittoHeaders); + return migrateThingDefinitionCommand; + }) + ) + )) + ); + } + + private static JsonObject addThingId(final JsonObject inputJson, final ThingId thingId) { + checkNotNull(inputJson, "inputJson"); + return JsonFactory.newObjectBuilder(inputJson) + .set(ThingCommand.JsonFields.JSON_THING_ID, thingId.toString()) + .build(); + } + private ThingDefinition getDefinitionFromJson(final String definitionJson, final DittoHeaders dittoHeaders) { return DittoJsonException.wrapJsonRuntimeException(definitionJson, dittoHeaders, (json, headers) -> { final ThingDefinition result; diff --git a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRouteTest.java b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRouteTest.java index 70a3812e0d..64a271b16c 100755 --- a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRouteTest.java +++ b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRouteTest.java @@ -154,6 +154,44 @@ public void putAndRetrieveNullDefinition() { getResult.assertStatusCode(StatusCodes.OK); } + @Test + public void putThingDefinitionSuccessfully() { + var thingId = EndpointTestConstants.KNOWN_THING_ID; + final var body = """ + { + "thingDefinitionUrl": "https://models.example.com/thing-definition-1.0.0.tm.jsonld", + "migrationPayload": { + "attributes": { + "manufacturer": "New Corp", + "location": "Berlin, main floor" + }, + "features": { + "thermostat": { + "properties": { + "status": { + "temperature": { + "value": 23.5, + "unit": "DEGREE_CELSIUS" + } + } + } + } + } + }, + "patchConditions": { + "thing:/features/thermostat": "not(exists(/features/thermostat))" + }, + "initializeMissingPropertiesFromDefaults": true + } + """; + + final RequestEntity requestEntity = HttpEntities.create(ContentTypes.APPLICATION_JSON, body); + final var result = underTest.run(HttpRequest.POST("/things/" + + thingId + "/migrateDefinition") + .withEntity(requestEntity)); + result.assertStatusCode(StatusCodes.OK); + } + @Test public void getThingsWithEmptyIdsList() { final var result = underTest.run(HttpRequest.GET("/things?ids=")); diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java new file mode 100644 index 0000000000..d49597a44a --- /dev/null +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java @@ -0,0 +1,312 @@ +/* + * Copyright (c) 2025 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.things.model.signals.commands.modify; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonParsableCommand; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.signals.FeatureToggle; +import org.eclipse.ditto.base.model.signals.UnsupportedSchemaVersionException; +import org.eclipse.ditto.base.model.signals.commands.AbstractCommand; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.policies.model.ResourceKey; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.signals.commands.ThingCommand; +import org.eclipse.ditto.things.model.signals.commands.ThingCommandSizeValidator; +import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingIdNotExplicitlySettableException; + +/** + * Command to update a Thing's definition and apply a migration payload. + */ +@Immutable +@JsonParsableCommand(typePrefix = ThingCommand.TYPE_PREFIX, name = MigrateThingDefinition.NAME) +public final class MigrateThingDefinition extends AbstractCommand + implements ThingModifyCommand { + + /** + * Name of the "Update Thing Definition" command. + */ + public static final String NAME = "migrateThingDefinition"; + + /** + * Type of this command. + */ + public static final String TYPE = TYPE_PREFIX + NAME; + + private final ThingId thingId; + private final String thingDefinitionUrl; + private final JsonObject migrationPayload; + private final Map patchConditions; + private final Boolean initializeMissingPropertiesFromDefaults; + + private MigrateThingDefinition(final ThingId thingId, + final String thingDefinitionUrl, + final JsonObject migrationPayload, + final Map patchConditions, + final Boolean initializeMissingPropertiesFromDefaults, + final DittoHeaders dittoHeaders) { + super(TYPE, FeatureToggle.checkMergeFeatureEnabled(TYPE, dittoHeaders)); + this.thingId = checkNotNull(thingId, "thingId"); + this.thingDefinitionUrl = checkNotNull(thingDefinitionUrl, "thingDefinitionUrl"); + this.migrationPayload = checkJsonSize(checkNotNull(migrationPayload, "migrationPayload"), dittoHeaders); + this.patchConditions = + Collections.unmodifiableMap(patchConditions != null ? patchConditions : Collections.emptyMap()); + this.initializeMissingPropertiesFromDefaults = initializeMissingPropertiesFromDefaults != null ? initializeMissingPropertiesFromDefaults : Boolean.FALSE; + checkSchemaVersion(); + } + + /** + * Factory method to create a new {@code UpdateThingDefinition} command. + * + * @param thingId the Thing ID. + * @param thingDefinitionUrl the URL of the new Thing definition. + * @param migrationPayload the migration payload. + * @param patchConditions the patch conditions. + * @param initializeProperties whether to initialize properties. + * @param dittoHeaders the Ditto headers. + * @return the created {@link MigrateThingDefinition} command. + */ + public static MigrateThingDefinition of(final ThingId thingId, + final String thingDefinitionUrl, + final JsonObject migrationPayload, + final Map patchConditions, + final Boolean initializeProperties, + final DittoHeaders dittoHeaders) { + return new MigrateThingDefinition(thingId, thingDefinitionUrl, migrationPayload, + patchConditions, initializeProperties, dittoHeaders); + } + + /** + * Creates a new {@code UpdateThingDefinition} from a JSON object. + * + * @param jsonObject the JSON object from which to create the command. + * @param dittoHeaders the Ditto headers. + * @return the created {@code UpdateThingDefinition} command. + */ + public static MigrateThingDefinition fromJson(final JsonObject jsonObject, final DittoHeaders dittoHeaders) { + final String thingIdStr = jsonObject.getValueOrThrow(ThingCommand.JsonFields.JSON_THING_ID); + final String thingDefinitionUrl = jsonObject.getValueOrThrow(JsonFields.JSON_THING_DEFINITION_URL); + final JsonObject migrationPayload = jsonObject.getValueOrThrow(JsonFields.JSON_MIGRATION_PAYLOAD).asObject(); + + final JsonObject patchConditionsJson = jsonObject.getValue(JsonFields.JSON_PATCH_CONDITIONS) + .map(JsonValue::asObject).orElse(JsonFactory.newObject()); + final Map patchConditions = patchConditionsJson.stream() + .collect(Collectors.toMap( + field -> ResourceKey.newInstance(field.getKey()), + field -> field.getValue().asString() + )); + + final Boolean initializeProperties = jsonObject.getValue(JsonFields.JSON_INITIALIZE_MISSING_PROPERTIES_FROM_DEFAULTS) + .orElse(Boolean.FALSE); + + return new MigrateThingDefinition( + ThingId.of(thingIdStr), + thingDefinitionUrl, + migrationPayload, + patchConditions, + initializeProperties, + dittoHeaders); + } + + /** + * Creates a command for updating the definition + * + * @param thingId the thing id. + * @param dittoHeaders the ditto headers. + * @return the created {@link MigrateThingDefinition} command. + */ + public static MigrateThingDefinition withThing(final ThingId thingId, final JsonObject jsonObject, final DittoHeaders dittoHeaders) { + ensureThingIdMatches(thingId, jsonObject); + return fromJson(jsonObject, dittoHeaders); + } + + @Override + public ThingId getEntityId() { + return thingId; + } + + public String getThingDefinitionUrl() { + return thingDefinitionUrl; + } + + public JsonObject getMigrationPayload() { + return migrationPayload; + } + + public Map getPatchConditions() { + return patchConditions; + } + + public Boolean isInitializeMissingPropertiesFromDefaults() { + return initializeMissingPropertiesFromDefaults; + } + + @Override + public Optional getEntity() { + // This command doesn't represent an entity directly. + return Optional.empty(); + } + + @Override + public Optional getEntity(final JsonSchemaVersion schemaVersion) { + return getEntity(); + } + + @Override + public MigrateThingDefinition setEntity(final JsonValue entity) { + return this; + } + + @Override + public JsonPointer getResourcePath() { + return JsonPointer.empty(); + } + + @Override + public boolean changesAuthorization() { + return false; + } + + @Override + public Category getCategory() { + return Category.MODIFY; + } + + @Override + public MigrateThingDefinition setDittoHeaders(final DittoHeaders dittoHeaders) { + return new MigrateThingDefinition( + thingId, thingDefinitionUrl, migrationPayload, patchConditions, initializeMissingPropertiesFromDefaults, dittoHeaders); + } + + @Override + protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, + final JsonSchemaVersion schemaVersion, + final Predicate predicateParameter) { + final Predicate predicate = schemaVersion.and(predicateParameter); + jsonObjectBuilder.set(ThingCommand.JsonFields.JSON_THING_ID, thingId.toString(), predicate); + jsonObjectBuilder.set(JsonFields.JSON_THING_DEFINITION_URL, thingDefinitionUrl, predicate); + jsonObjectBuilder.set(JsonFields.JSON_MIGRATION_PAYLOAD, migrationPayload, predicate); + jsonObjectBuilder.set(JsonFields.JSON_INITIALIZE_MISSING_PROPERTIES_FROM_DEFAULTS, initializeMissingPropertiesFromDefaults, predicate); + + if (!patchConditions.isEmpty()) { + final JsonObjectBuilder conditionsBuilder = JsonFactory.newObjectBuilder(); + patchConditions.forEach(conditionsBuilder::set); + jsonObjectBuilder.set(JsonFields.JSON_PATCH_CONDITIONS, conditionsBuilder.build(), predicate); + } + } + + + /** + * Ensures that the thingId is consistent with the id of the thing. + * + * @throws org.eclipse.ditto.things.model.signals.commands.exceptions.ThingIdNotExplicitlySettableException if ids do not match. + */ + private static void ensureThingIdMatches(final ThingId thingId, final JsonObject jsonObject) { + if (!jsonObject.getValueOrThrow(ThingCommand.JsonFields.JSON_THING_ID).equals(thingId.toString())) { + throw ThingIdNotExplicitlySettableException.forDittoProtocol().build(); + } + } + + + @Override + public JsonSchemaVersion[] getSupportedSchemaVersions() { + return new JsonSchemaVersion[]{JsonSchemaVersion.V_2}; + } + + private void checkSchemaVersion() { + final JsonSchemaVersion implementedSchemaVersion = getImplementedSchemaVersion(); + if (!implementsSchemaVersion(implementedSchemaVersion)) { + throw UnsupportedSchemaVersionException.newBuilder(implementedSchemaVersion).build(); + } + } + + private JsonObject checkJsonSize(final JsonObject value, final DittoHeaders dittoHeaders) { + ThingCommandSizeValidator.getInstance().ensureValidSize( + value::getUpperBoundForStringSize, + () -> value.toString().length(), + () -> dittoHeaders); + return value; + } + + /** + * An enumeration of the JSON fields of an {@code UpdateThingDefinition} command. + */ + @Immutable + static final class JsonFields { + + static final JsonFieldDefinition JSON_THING_DEFINITION_URL = + JsonFactory.newStringFieldDefinition("thingDefinitionUrl", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition JSON_MIGRATION_PAYLOAD = + JsonFactory.newJsonObjectFieldDefinition("migrationPayload", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition JSON_PATCH_CONDITIONS = + JsonFactory.newJsonObjectFieldDefinition("patchConditions", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition JSON_INITIALIZE_MISSING_PROPERTIES_FROM_DEFAULTS = + JsonFactory.newBooleanFieldDefinition("initializeMissingPropertiesFromDefaults", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private JsonFields() { + throw new AssertionError(); + } + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (!(o instanceof MigrateThingDefinition)) return false; + if (!super.equals(o)) return false; + final MigrateThingDefinition that = (MigrateThingDefinition) o; + return initializeMissingPropertiesFromDefaults == that.initializeMissingPropertiesFromDefaults && + thingId.equals(that.thingId) && + thingDefinitionUrl.equals(that.thingDefinitionUrl) && + migrationPayload.equals(that.migrationPayload) && + patchConditions.equals(that.patchConditions); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), thingId, thingDefinitionUrl, migrationPayload, patchConditions, + initializeMissingPropertiesFromDefaults); + } + + @Override + public String toString() { + return "UpdateThingDefinition{" + + "thingId=" + thingId + + ", thingDefinitionUrl='" + thingDefinitionUrl + '\'' + + ", migrationPayload=" + migrationPayload + + ", patchConditions=" + patchConditions + + ", initializeMissingPropertiesFromDefaults=" + initializeMissingPropertiesFromDefaults + + ", dittoHeaders=" + getDittoHeaders() + + '}'; + } +} diff --git a/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java new file mode 100644 index 0000000000..ea03a72b94 --- /dev/null +++ b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 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.things.model.signals.commands.modify; + + +import static org.eclipse.ditto.things.model.signals.commands.assertions.ThingCommandAssertions.assertThat; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.assertj.core.api.Assertions; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.policies.model.ResourceKey; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingIdNotExplicitlySettableException; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +public final class MigrateThingDefinitionTest { + + private static final DittoHeaders DITTO_HEADERS = + DittoHeaders.newBuilder().correlationId(UUID.randomUUID().toString()).build(); + + private static final JsonObject MIGRATION_PAYLOAD = JsonFactory.newObjectBuilder() + .set("attributes", JsonFactory.newObjectBuilder().set("manufacturer", "New Corp").build()) + .set("features", JsonFactory.newObjectBuilder() + .set("thermostat", JsonFactory.newObjectBuilder() + .set("properties", JsonFactory.newObjectBuilder() + .set("status", JsonFactory.newObjectBuilder() + .set("temperature", JsonFactory.newObjectBuilder() + .set("value", 23.5) + .set("unit", "DEGREE_CELSIUS") + .build()) + .build()) + .build()) + .build()) + .build()) + .build(); + + private static final Map PATCH_CONDITIONS = new HashMap<>(); + + static { + PATCH_CONDITIONS.put(ResourceKey.newInstance("thing:/features/thermostat"), "not(exists(/features/thermostat))"); + } + + private static final ThingId THING_ID = ThingId.of("namespace", "my-thing"); + + @Test + public void testHashCodeAndEquals() { + EqualsVerifier.forClass(MigrateThingDefinition.class) + .withRedefinedSuperclass() + .verify(); + } + + @Test + public void createUpdateThingDefinitionSuccessfully() { + final MigrateThingDefinition command = MigrateThingDefinition.of( + THING_ID, + "https://models.example.com/thing-definition-1.0.0.tm.jsonld", + MIGRATION_PAYLOAD, + PATCH_CONDITIONS, + true, + DITTO_HEADERS + ); + + assertThat(command.getEntityId().toString()).isEqualTo(THING_ID.toString()); + assertThat(command.getThingDefinitionUrl()).isEqualTo("https://models.example.com/thing-definition-1.0.0.tm.jsonld"); + assertThat(command.getMigrationPayload()).isEqualTo(MIGRATION_PAYLOAD); + assertThat(command.getPatchConditions()).isEqualTo(PATCH_CONDITIONS); + assertThat(command.isInitializeMissingPropertiesFromDefaults()).isTrue(); + } + + @Test + public void createUpdateThingDefinitionWithEmptyPatchConditions() { + final MigrateThingDefinition command = MigrateThingDefinition.of( + THING_ID, + "https://models.example.com/thing-definition-1.0.0.tm.jsonld", + MIGRATION_PAYLOAD, + Collections.emptyMap(), + false, + DITTO_HEADERS + ); + + assertThat(command.getPatchConditions()).isEmpty(); + assertThat(command.isInitializeMissingPropertiesFromDefaults()).isFalse(); + } + + @Test(expected = NullPointerException.class) + public void createUpdateThingDefinitionWithNullThingId() { + MigrateThingDefinition.of( + null, + "https://models.example.com/thing-definition-1.0.0.tm.jsonld", + MIGRATION_PAYLOAD, + PATCH_CONDITIONS, + true, + DITTO_HEADERS + ); + } + + @Test + public void testFromJson() { + final JsonObject json = JsonFactory.newObjectBuilder() + .set("thingId", THING_ID.toString()) + .set("thingDefinitionUrl", "https://models.example.com/thing-definition-1.0.0.tm.jsonld") + .set("migrationPayload", MIGRATION_PAYLOAD) + .set("patchConditions", JsonFactory.newObjectBuilder() + .set("thing:/features/thermostat", "not(exists(/features/thermostat))") + .build()) + .set("initializeProperties", true) + .build(); + + final MigrateThingDefinition command = MigrateThingDefinition.fromJson(json, DITTO_HEADERS); + + assertThat(command.getEntityId().toString()).isEqualTo(THING_ID.toString()); + assertThat(command.getThingDefinitionUrl()).isEqualTo("https://models.example.com/thing-definition-1.0.0.tm.jsonld"); + assertThat(command.getMigrationPayload()).isEqualTo(MIGRATION_PAYLOAD); + assertThat(command.getPatchConditions()).isEqualTo(PATCH_CONDITIONS); + assertThat(command.isInitializeMissingPropertiesFromDefaults()).isTrue(); + } + + @Test(expected = ThingIdNotExplicitlySettableException.class) + public void testEnsureThingIdMismatchThrowsException() { + final JsonObject json = JsonFactory.newObjectBuilder() + .set("thingId", "another-thing") + .set("thingDefinitionUrl", "https://models.example.com/thing-definition-1.0.0.tm.jsonld") + .set("migrationPayload", MIGRATION_PAYLOAD) + .set("patchConditions", JsonFactory.newObjectBuilder().build()) + .set("initializeProperties", false) + .build(); + + MigrateThingDefinition.withThing(THING_ID, json, DITTO_HEADERS); + } + + @Test + public void toJsonReturnsExpected() { + final MigrateThingDefinition command = MigrateThingDefinition.of( + THING_ID, + "https://models.example.com/thing-definition-1.0.0.tm.jsonld", + MIGRATION_PAYLOAD, + PATCH_CONDITIONS, + true, + DITTO_HEADERS + ); + + final JsonObject json = command.toJson(); + + Assertions.assertThat(json.getValueOrThrow(JsonFieldDefinition.ofString("thingId"))).isEqualTo(THING_ID.toString()); + Assertions.assertThat(json.getValueOrThrow(JsonFieldDefinition.ofString("thingDefinitionUrl"))).isEqualTo("https://models.example.com/thing-definition-1.0.0.tm.jsonld"); + Assertions.assertThat(json.getValueOrThrow(JsonFieldDefinition.ofJsonObject("migrationPayload"))).isEqualTo(MIGRATION_PAYLOAD); + Assertions.assertThat(json.getValueOrThrow(JsonFieldDefinition.ofBoolean("initializeProperties"))).isTrue(); + } + + @Test + public void testUpdateThingDefinitionEquality() { + final MigrateThingDefinition command1 = MigrateThingDefinition.of( + THING_ID, + "https://models.example.com/thing-definition-1.0.0.tm.jsonld", + MIGRATION_PAYLOAD, + PATCH_CONDITIONS, + true, + DITTO_HEADERS + ); + + final MigrateThingDefinition command2 = MigrateThingDefinition.of( + THING_ID, + "https://models.example.com/thing-definition-1.0.0.tm.jsonld", + MIGRATION_PAYLOAD, + PATCH_CONDITIONS, + true, + DITTO_HEADERS + ); + + Assertions.assertThat(command1).isEqualTo(command2); + } +} + diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java new file mode 100644 index 0000000000..3a1d6f9e5c --- /dev/null +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2025 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.things.service.persistence.actors.strategies.commands; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.apache.pekko.actor.ActorSystem; +import org.eclipse.ditto.base.model.entity.metadata.Metadata; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.headers.entitytag.EntityTag; +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.internal.utils.persistentactors.results.Result; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.placeholders.PlaceholderFactory; +import org.eclipse.ditto.placeholders.TimePlaceholder; +import org.eclipse.ditto.policies.model.ResourceKey; +import org.eclipse.ditto.rql.parser.RqlPredicateParser; +import org.eclipse.ditto.rql.query.filter.QueryFilterCriteriaFactory; +import org.eclipse.ditto.rql.query.things.ThingPredicateVisitor; +import org.eclipse.ditto.things.model.Thing; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.ThingsModelFactory; +import org.eclipse.ditto.things.model.signals.commands.ThingCommandSizeValidator; +import org.eclipse.ditto.things.model.signals.commands.ThingResourceMapper; +import org.eclipse.ditto.things.model.signals.commands.modify.MergeThing; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; +import org.eclipse.ditto.things.model.signals.events.ThingEvent; + + +/** + * Strategy to handle the {@link MigrateThingDefinition} command. + * + * This strategy processes updates to a Thing's definition, applying necessary data migrations + * and ensuring that defaults are properly initialized when required. + * + * Assumptions: + * - The {@link MigrateThingDefinition} command provides a ThingDefinition URL, which is used + * to create a skeleton Thing. The command's payload also includes migration data and patch + * conditions for fine-grained updates. + * - Patch conditions are evaluated using RQL-based expressions to determine which migration + * payload entries should be applied. + * - Skeleton generation extracts and merges Thing definitions and default values separately, + * ensuring a clear distinction between structural updates and default settings. + * - After applying skeleton-based modifications and migration payloads, the changes are merged + * - Property initialization can be optionally enabled via the command, applying default values + * to the updated Thing when set to true. + * - The resulting Thing undergoes validation to ensure compliance with WoT model constraints + * before persisting changes. + */ +@Immutable +public final class MigrateThingDefinitionStrategy extends AbstractThingModifyCommandStrategy { + + private static final ThingResourceMapper> ENTITY_TAG_MAPPER = + ThingResourceMapper.from(EntityTagCalculator.getInstance()); + + private static final TimePlaceholder TIME_PLACEHOLDER = TimePlaceholder.getInstance(); + + + public MigrateThingDefinitionStrategy(final ActorSystem actorSystem) { + super(MigrateThingDefinition.class, actorSystem); + } + + @Override + protected Result> doApply(final Context context, + @Nullable final Thing thing, + final long nextRevision, + final MigrateThingDefinition command, + @Nullable final Metadata metadata) { + + final Thing existingThing = getEntityOrThrow(thing); + final Instant eventTs = getEventTimestamp(); + + return handleUpdateDefinition(context, existingThing, eventTs, nextRevision, command, metadata) + .toCompletableFuture() + .join(); + } + + private CompletionStage>> handleUpdateDefinition(final Context context, + final Thing existingThing, + final Instant eventTs, + final long nextRevision, + final MigrateThingDefinition command, + @Nullable final Metadata metadata) { + + final DittoHeaders dittoHeaders = command.getDittoHeaders(); + + // 1. Evaluate Patch Conditions and modify the migrationPayload + final JsonObject adjustedMigrationPayload = evaluatePatchConditions( + existingThing, + command.getMigrationPayload(), + command.getPatchConditions(), + dittoHeaders); + + // 2. Generate Skeleton using definition + final CompletionStage skeletonStage = generateSkeleton(context, command, dittoHeaders); + + // 3. Merge Skeleton with Existing Thing and Apply Migration Payload + final CompletionStage updatedThingStage = skeletonStage.thenApply(skeleton -> { + final Thing mergedThing = mergeSkeletonWithThing(existingThing, skeleton, command.getThingDefinitionUrl(), command.isInitializeMissingPropertiesFromDefaults()); + return applyMigrationPayload(mergedThing, adjustedMigrationPayload, dittoHeaders, nextRevision, eventTs); + }); + + // 4. Validate and Build Result + return updatedThingStage.thenCompose(updatedThing -> + buildValidatedStage(command, existingThing, updatedThing) + .thenCompose(validatedCommand -> { + final MergeThing mergeThingCommand = MergeThing.of( + command.getEntityId(), + JsonPointer.empty(), + updatedThing.toJson(), + dittoHeaders + ); + + final MergeThingStrategy mergeStrategy = new MergeThingStrategy(context.getActorSystem()); + final Result> mergeResult = mergeStrategy.doApply( + context, + existingThing, + nextRevision, + mergeThingCommand, + metadata + ); + + return CompletableFuture.completedFuture(mergeResult); + }) + ); + } + + private JsonObject evaluatePatchConditions(final Thing existingThing, + final JsonObject migrationPayload, + final Map patchConditions, + final DittoHeaders dittoHeaders) { + final JsonObjectBuilder adjustedPayloadBuilder = migrationPayload.toBuilder(); + + for (Map.Entry entry : patchConditions.entrySet()) { + final ResourceKey resourceKey = entry.getKey(); + final String conditionExpression = entry.getValue(); + + final boolean conditionMatches = evaluateCondition(existingThing, conditionExpression, dittoHeaders); + + final JsonPointer resourcePointer = JsonFactory.newPointer(resourceKey.getResourcePath()); + if (!conditionMatches && doesMigrationPayloadContainResourceKey(migrationPayload, resourcePointer)) { + adjustedPayloadBuilder.remove(resourcePointer); + } + } + + return adjustedPayloadBuilder.build(); + } + public boolean doesMigrationPayloadContainResourceKey(JsonObject migrationPayload, JsonPointer pointer) { + return migrationPayload.getValue(pointer).isPresent(); + } + private boolean evaluateCondition(final Thing existingThing, + final String conditionExpression, + final DittoHeaders dittoHeaders) { + try { + final var criteria = QueryFilterCriteriaFactory + .modelBased(RqlPredicateParser.getInstance()) + .filterCriteria(conditionExpression, dittoHeaders); + + final var predicate = ThingPredicateVisitor.apply(criteria, + PlaceholderFactory.newPlaceholderResolver(TIME_PLACEHOLDER, new Object())); + + return predicate.test(existingThing); + } catch (Exception e) { + return false; + } + } + + private CompletionStage generateSkeleton(final Context context, + final MigrateThingDefinition command, + final DittoHeaders dittoHeaders) { + return wotThingSkeletonGenerator.provideThingSkeletonForCreation( + command.getEntityId(), + ThingsModelFactory.newDefinition(command.getThingDefinitionUrl()), + dittoHeaders + ) + .thenApply(optionalSkeleton -> optionalSkeleton.orElseThrow()); + } + + + private Thing extractDefinitions(final Thing thing, final String thingDefinitionUrl) { + var thingBuilder = ThingsModelFactory.newThingBuilder(); + thingBuilder.setDefinition(ThingsModelFactory.newDefinition(thingDefinitionUrl)); + thing.getFeatures().orElseGet(null).forEach(feature-> { + thingBuilder.setFeature(feature.getId(), feature.getDefinition().get(), null); + }); + return thingBuilder.build(); + } + + + private Thing extractDefaultValues(Thing thing) { + var thingBuilder = ThingsModelFactory.newThingBuilder(); + thingBuilder.setAttributes(thing.getAttributes().get()); + thing.getFeatures().orElseGet(null).forEach(feature-> { + thingBuilder.setFeature(feature.getId(), feature.getProperties().get()); + }); + return thingBuilder.build(); + } + + + private Thing mergeSkeletonWithThing(final Thing existingThing, final Thing skeletonThing, + final String thingDefinitionUrl, final boolean isInitializeProperties) { + + // Extract definitions and convert to JSON + var fullThingDefinitions = extractDefinitions(skeletonThing, thingDefinitionUrl).toJson(); + + // Merge the extracted definitions with the existing thing JSON + var mergedThingJson = JsonFactory.mergeJsonValues(fullThingDefinitions, existingThing.toJson()).asObject(); + + // If not initializing properties, return the merged result + if (!isInitializeProperties) { + return ThingsModelFactory.newThing(mergedThingJson); + } + + // Extract default values and merge them in + return ThingsModelFactory.newThing(JsonFactory.mergeJsonValues( + mergedThingJson, extractDefaultValues(skeletonThing).toJson()).asObject()); + } + + private Thing applyMigrationPayload(final Thing thing, + final JsonObject migrationPayload, + final DittoHeaders dittoHeaders, + final long nextRevision, + final Instant eventTs) { + final JsonObject thingJson = thing.toJson(FieldType.all()); + + final JsonObject mergePatch = JsonFactory.newObject(JsonPointer.empty(), migrationPayload); + final JsonObject mergedJson = JsonFactory.mergeJsonValues(mergePatch, thingJson).asObject(); + + ThingCommandSizeValidator.getInstance().ensureValidSize( + mergedJson::getUpperBoundForStringSize, + () -> mergedJson.toString().length(), + () -> dittoHeaders); + + return ThingsModelFactory.newThingBuilder(mergedJson) + .setModified(eventTs) + .setRevision(nextRevision) + .build(); + } + + @Override + public Optional previousEntityTag(final MigrateThingDefinition command, @Nullable final Thing previousEntity) { + return ENTITY_TAG_MAPPER.map(JsonPointer.empty(), previousEntity); + } + + @Override + public Optional nextEntityTag(final MigrateThingDefinition command, @Nullable final Thing newEntity) { + return ENTITY_TAG_MAPPER.map(JsonPointer.empty(), getEntityOrThrow(newEntity)); + } + + @Override + protected CompletionStage performWotValidation(final MigrateThingDefinition command, + @Nullable final Thing previousThing, @Nullable final Thing previewThing) { + return wotThingModelValidator.validateThing( + Optional.ofNullable(previewThing).orElseThrow(), + command.getResourcePath(), + command.getDittoHeaders() + ).thenApply(aVoid -> command); + } +} diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingCommandStrategies.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingCommandStrategies.java index 6aeca0f6b6..266e4753f6 100644 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingCommandStrategies.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingCommandStrategies.java @@ -107,6 +107,7 @@ private void addDefinitionStrategies(final ActorSystem system) { addStrategy(new ModifyThingDefinitionStrategy(system)); addStrategy(new RetrieveThingDefinitionStrategy(system)); addStrategy(new DeleteThingDefinitionStrategy(system)); + addStrategy(new MigrateThingDefinitionStrategy(system)); } private void addFeaturesStrategies(final ActorSystem system) { From 7125a946dfa77ff2d008e495daa429b48529bfcf Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Mon, 27 Jan 2025 20:11:53 +0100 Subject: [PATCH 02/15] fix test --- .../signals/commands/modify/MigrateThingDefinitionTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java index ea03a72b94..df4d4f453c 100644 --- a/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java +++ b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java @@ -122,7 +122,7 @@ public void testFromJson() { .set("patchConditions", JsonFactory.newObjectBuilder() .set("thing:/features/thermostat", "not(exists(/features/thermostat))") .build()) - .set("initializeProperties", true) + .set("initializeMissingPropertiesFromDefaults", true) .build(); final MigrateThingDefinition command = MigrateThingDefinition.fromJson(json, DITTO_HEADERS); @@ -163,7 +163,7 @@ public void toJsonReturnsExpected() { Assertions.assertThat(json.getValueOrThrow(JsonFieldDefinition.ofString("thingId"))).isEqualTo(THING_ID.toString()); Assertions.assertThat(json.getValueOrThrow(JsonFieldDefinition.ofString("thingDefinitionUrl"))).isEqualTo("https://models.example.com/thing-definition-1.0.0.tm.jsonld"); Assertions.assertThat(json.getValueOrThrow(JsonFieldDefinition.ofJsonObject("migrationPayload"))).isEqualTo(MIGRATION_PAYLOAD); - Assertions.assertThat(json.getValueOrThrow(JsonFieldDefinition.ofBoolean("initializeProperties"))).isTrue(); + Assertions.assertThat(json.getValueOrThrow(JsonFieldDefinition.ofBoolean("initializeMissingPropertiesFromDefaults"))).isTrue(); } @Test From 56334c4c25cb66c50ef0c2470da6268869e145f7 Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Tue, 4 Feb 2025 01:32:57 +0100 Subject: [PATCH 03/15] add the new command to ditto protocol and modify openapi spec --- .../resources/openapi/sources/api-2-index.yml | 4 +- .../paths/things/migrateDefinition.yml | 86 ++++++++++ .../openapi/sources/paths/things/thing.yml | 78 +-------- .../things/migrateThingDefinitionRequest.yml | 3 +- .../resources/pages/ditto/httpapi-concepts.md | 2 +- .../endpoints/routes/things/ThingsRoute.java | 4 +- .../routes/things/ThingsRouteTest.java | 2 +- .../protocol/CommandsTopicPathBuilder.java | 7 + .../ditto/protocol/ImmutableTopicPath.java | 6 + .../org/eclipse/ditto/protocol/TopicPath.java | 4 +- .../adapter/AdapterResolverBySignal.java | 9 +- .../MigrateDefinitionCommandAdapter.java | 42 +++++ ...grateDefinitionCommandAdapterProvider.java | 30 ++++ .../provider/ThingCommandAdapterProvider.java | 3 +- .../DefaultThingCommandAdapterProvider.java | 11 +- .../things/ThingMigrateCommandAdapter.java | 52 ++++++ .../mapper/AbstractCommandSignalMapper.java | 3 + .../protocol/mapper/SignalMapperFactory.java | 4 + .../mapper/ThingMigrateSignalMapper.java | 42 +++++ .../MappingStrategiesFactory.java | 3 + .../ThingMigrateCommandMappingStrategies.java | 77 +++++++++ .../eclipse/ditto/protocol/LiveTwinTest.java | 3 + .../ThingMigrateCommandAdapterTest.java | 103 ++++++++++++ .../SkeletonGenerationFailedException.java | 123 ++++++++++++++ .../modify/MigrateThingDefinition.java | 60 ++----- .../modify/MigrateThingDefinitionTest.java | 13 -- .../MigrateThingDefinitionStrategy.java | 158 ++++++++++-------- .../commands/ThingCommandStrategies.java | 2 +- 28 files changed, 715 insertions(+), 219 deletions(-) create mode 100644 documentation/src/main/resources/openapi/sources/paths/things/migrateDefinition.yml create mode 100644 protocol/src/main/java/org/eclipse/ditto/protocol/adapter/MigrateDefinitionCommandAdapter.java create mode 100644 protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/MigrateDefinitionCommandAdapterProvider.java create mode 100644 protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/ThingMigrateCommandAdapter.java create mode 100644 protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigrateSignalMapper.java create mode 100644 protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/ThingMigrateCommandMappingStrategies.java create mode 100644 protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigrateCommandAdapterTest.java create mode 100644 things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/exceptions/SkeletonGenerationFailedException.java diff --git a/documentation/src/main/resources/openapi/sources/api-2-index.yml b/documentation/src/main/resources/openapi/sources/api-2-index.yml index c599585458..1e20a22357 100644 --- a/documentation/src/main/resources/openapi/sources/api-2-index.yml +++ b/documentation/src/main/resources/openapi/sources/api-2-index.yml @@ -60,8 +60,8 @@ paths: $ref: "./paths/things/index.yml" '/api/2/things/{thingId}': $ref: "./paths/things/thing.yml" - '/api/2/things/{thingId}/MigrateDefinition': - $ref: "./paths/things/thing.yml" + '/api/2/things/{thingId}/migrateDefinition': + $ref: "./paths/things/migrateDefinition.yml" '/api/2/things/{thingId}/definition': $ref: "./paths/things/definition.yml" '/api/2/things/{thingId}/policyId': diff --git a/documentation/src/main/resources/openapi/sources/paths/things/migrateDefinition.yml b/documentation/src/main/resources/openapi/sources/paths/things/migrateDefinition.yml new file mode 100644 index 0000000000..043588169b --- /dev/null +++ b/documentation/src/main/resources/openapi/sources/paths/things/migrateDefinition.yml @@ -0,0 +1,86 @@ +# Copyright (c) 2025 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 +post: + summary: Update the definition of an existing Thing + description: |- + Updates the definition of the specified thing by providing a new definition URL along with an optional migration payload. + + The request body allows specifying: + - A new Thing definition URL. + - A migration payload containing updates to attributes and features. + - Patch conditions to ensure consistent updates. + - Whether properties should be initialized if missing. + + ### Example: + ```json + { + "thingDefinitionUrl": "https://example.com/new-thing-definition.json", + "migrationPayload": { + "attributes": { + "manufacturer": "New Corp" + }, + "features": { + "sensor": { + "properties": { + "status": { + "temperature": { + "value": 25.0 + } + } + } + } + } + }, + "patchConditions": { + "thing:/features/sensor": "not(exists(/features/sensor))" + }, + "initializeMissingPropertiesFromDefaults": true + } + ``` + + tags: + - Things + parameters: + - $ref: '../../parameters/thingIdPathParam.yml' + requestBody: + description: JSON payload containing the new definition URL, migration payload, patch conditions, and initialization flag. + required: true + content: + application/json: + schema: + $ref: '../../schemas/things/migrateThingDefinitionRequest.yml' + responses: + '204': + description: The thing definition was successfully updated. No content is returned. + '400': + description: The request could not be processed due to invalid input. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '401': + description: Unauthorized request due to missing authentication. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '404': + description: The specified thing could not be found. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '412': + description: The update conditions were not met. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' \ No newline at end of file diff --git a/documentation/src/main/resources/openapi/sources/paths/things/thing.yml b/documentation/src/main/resources/openapi/sources/paths/things/thing.yml index 5efc3f567c..25c7cb290d 100644 --- a/documentation/src/main/resources/openapi/sources/paths/things/thing.yml +++ b/documentation/src/main/resources/openapi/sources/paths/things/thing.yml @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Contributors to the Eclipse Foundation +# Copyright (c) 2020 Contributors to the Eclipse Foundation # # See the NOTICE file(s) distributed with this work for additional # information regarding copyright ownership. @@ -502,82 +502,6 @@ patch: smartMode: null tempToHold: 50 description: JSON representation of the thing to be patched. -/post: - summary: Update the definition of an existing Thing - description: |- - Updates the definition of the specified thing by providing a new definition URL along with an optional migration payload. - - The request body allows specifying: - - A new Thing definition URL. - - A migration payload containing updates to attributes and features. - - Patch conditions to ensure consistent updates. - - Whether properties should be initialized if missing. - - ### Example: - ```json - { - "thingDefinitionUrl": "https://example.com/new-thing-definition.json", - "migrationPayload": { - "attributes": { - "manufacturer": "New Corp" - }, - "features": { - "sensor": { - "properties": { - "status": { - "temperature": { - "value": 25.0 - } - } - } - } - } - }, - "patchConditions": { - "thing:/features/sensor": "not(exists(/features/sensor))" - }, - "initializeMissingPropertiesFromDefaults": true - } - ``` - - tags: - - Things - parameters: - - $ref: '../../parameters/thingIdPathParam.yml' - requestBody: - description: JSON payload containing the new definition URL, migration payload, patch conditions, and initialization flag. - required: true - content: - application/json: - schema: - $ref: '../../schemas/things/migrateThingDefinitionRequest.yml' - responses: - '204': - description: The thing definition was successfully updated. No content is returned. - '400': - description: The request could not be processed due to invalid input. - content: - application/json: - schema: - $ref: '../../schemas/errors/advancedError.yml' - '401': - description: Unauthorized request due to missing authentication. - content: - application/json: - schema: - $ref: '../../schemas/errors/advancedError.yml' - '404': - description: The specified thing could not be found. - content: - application/json: - schema: - $ref: '../../schemas/errors/advancedError.yml' - '412': - description: The update conditions were not met. - content: - application/json: - schema: - $ref: '../../schemas/errors/advancedError.yml' delete: summary: Delete a specific thing description: |- diff --git a/documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionRequest.yml b/documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionRequest.yml index 6bffa3d3b3..56be103fec 100644 --- a/documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionRequest.yml +++ b/documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionRequest.yml @@ -58,8 +58,7 @@ properties: type: boolean description: "Flag indicating whether missing properties should be initialized with default values." example: true + default: false required: - - thingId - thingDefinitionUrl - - migrationPayload diff --git a/documentation/src/main/resources/pages/ditto/httpapi-concepts.md b/documentation/src/main/resources/pages/ditto/httpapi-concepts.md index 0853133697..c1fc34b385 100644 --- a/documentation/src/main/resources/pages/ditto/httpapi-concepts.md +++ b/documentation/src/main/resources/pages/ditto/httpapi-concepts.md @@ -106,7 +106,7 @@ The following additional API endpoints are automatically available: * `/things/{thingId}/features/lamp/properties/color`: accessing the `color` properties of the feature `lamp` of the specific thing -#### `/things` in API 2 - update-definition +#### `/things` in API 2 - migrateDefinition Migrate Thing Definitions The endpoint `/things/{thingId}/migrateDefinition`allows migrating the thing definition with a new model, as well as optionally migrating attributes and features. diff --git a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java index 53ae7bfe36..600953e7bb 100755 --- a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java +++ b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Contributors to the Eclipse Foundation + * Copyright (c) 2024 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -594,7 +594,7 @@ private Route thingsEntryMigrateDefinition(final RequestContext ctx, final JsonObject updatedJson = addThingId(inputJson, thingId); final MigrateThingDefinition migrateThingDefinitionCommand = - MigrateThingDefinition.withThing(thingId, updatedJson, dittoHeaders); + MigrateThingDefinition.fromJson(updatedJson, dittoHeaders); return migrateThingDefinitionCommand; }) ) diff --git a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRouteTest.java b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRouteTest.java index 64a271b16c..fff390be21 100755 --- a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRouteTest.java +++ b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRouteTest.java @@ -155,7 +155,7 @@ public void putAndRetrieveNullDefinition() { } @Test - public void putThingDefinitionSuccessfully() { + public void postMigrateThingDefinitionSuccessfully() { var thingId = EndpointTestConstants.KNOWN_THING_ID; final var body = """ { diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/CommandsTopicPathBuilder.java b/protocol/src/main/java/org/eclipse/ditto/protocol/CommandsTopicPathBuilder.java index a7d8330749..a5aa09f18f 100755 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/CommandsTopicPathBuilder.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/CommandsTopicPathBuilder.java @@ -54,4 +54,11 @@ public interface CommandsTopicPathBuilder extends TopicPathBuildable { */ CommandsTopicPathBuilder delete(); + /** + * Sets the {@code Action} of this builder to {@link TopicPath.Action#MIGRATE}. A previously set action is replaced. + * + * @return this builder to allow method chaining. + */ + CommandsTopicPathBuilder migrate(); + } diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/ImmutableTopicPath.java b/protocol/src/main/java/org/eclipse/ditto/protocol/ImmutableTopicPath.java index 8bcc89c638..c66997792d 100755 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/ImmutableTopicPath.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/ImmutableTopicPath.java @@ -393,6 +393,12 @@ public CommandsTopicPathBuilder delete() { return this; } + @Override + public CommandsTopicPathBuilder migrate() { + action = Action.MIGRATE; + return this; + } + @Override public TopicPathBuildable subscribe() { searchAction = SearchAction.SUBSCRIBE; diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPath.java b/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPath.java index 6fa1c1f090..3880b15be6 100755 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPath.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPath.java @@ -414,7 +414,9 @@ enum Action { MERGED("merged"), - DELETED("deleted"); + DELETED("deleted"), + + MIGRATE("migrate"); private final String name; diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/AdapterResolverBySignal.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/AdapterResolverBySignal.java index 6e8ca806a4..9c85df64f0 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/AdapterResolverBySignal.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/AdapterResolverBySignal.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 Contributors to the Eclipse Foundation + * Copyright (c) 2025 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -46,6 +46,7 @@ import org.eclipse.ditto.things.model.signals.commands.ThingErrorResponse; import org.eclipse.ditto.things.model.signals.commands.modify.MergeThing; import org.eclipse.ditto.things.model.signals.commands.modify.MergeThingResponse; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; import org.eclipse.ditto.things.model.signals.commands.modify.ThingModifyCommand; import org.eclipse.ditto.things.model.signals.commands.modify.ThingModifyCommandResponse; import org.eclipse.ditto.things.model.signals.commands.query.RetrieveThings; @@ -230,6 +231,12 @@ private > Adapter resolveCommand(final Command command validateNotLive(command); return (Adapter) thingsAdapters.getSearchCommandAdapter(); } + + if (command instanceof MigrateThingDefinition) { + validateNotLive(command); + return (Adapter) thingsAdapters.getMigrateDefinitionCommandAdapter(); + } + if (command instanceof StreamingSubscriptionCommand) { validateNotLive(command); return (Adapter) streamingSubscriptionCommandAdapter; diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/MigrateDefinitionCommandAdapter.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/MigrateDefinitionCommandAdapter.java new file mode 100644 index 0000000000..2db38aa09f --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/MigrateDefinitionCommandAdapter.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 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.protocol.adapter; + +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; + +import java.util.EnumSet; +import java.util.Set; + +/** + * An adapter interface for handling {@link MigrateThingDefinition} commands in Ditto. + * + * @since 3.7.0 + */ +public interface MigrateDefinitionCommandAdapter extends Adapter { + + @Override + default Set getCriteria() { + return EnumSet.of(TopicPath.Criterion.COMMANDS); + } + + @Override + default Set getActions() { + return EnumSet.of(TopicPath.Action.MIGRATE); + } + + @Override + default boolean isForResponses() { + return false; + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/MigrateDefinitionCommandAdapterProvider.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/MigrateDefinitionCommandAdapterProvider.java new file mode 100644 index 0000000000..c63259f3f9 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/MigrateDefinitionCommandAdapterProvider.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 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.protocol.adapter.provider; + +import org.eclipse.ditto.protocol.adapter.Adapter; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; + +/** + * Interface providing the migrateThingDefinition command adapter. + * + * @since 3.7.0 + */ +interface MigrateDefinitionCommandAdapterProvider { + + /** + * @return the migrate definition command adapter + */ + Adapter getMigrateDefinitionCommandAdapter(); + +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/ThingCommandAdapterProvider.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/ThingCommandAdapterProvider.java index e8266a94ad..fb2a360c26 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/ThingCommandAdapterProvider.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/ThingCommandAdapterProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 Contributors to the Eclipse Foundation + * Copyright (c) 2025 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -38,5 +38,6 @@ public interface ThingCommandAdapterProvider MergeEventAdapterProvider, SubscriptionEventAdapterProvider>, ThingSearchCommandAdapterProvider>, + MigrateDefinitionCommandAdapterProvider, AdapterProvider { } diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/DefaultThingCommandAdapterProvider.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/DefaultThingCommandAdapterProvider.java index 529ce16a18..c1a77eb79b 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/DefaultThingCommandAdapterProvider.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/DefaultThingCommandAdapterProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 Contributors to the Eclipse Foundation + * Copyright (c) 2025 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -25,6 +25,7 @@ import org.eclipse.ditto.things.model.signals.commands.ThingErrorResponse; import org.eclipse.ditto.things.model.signals.commands.modify.MergeThing; import org.eclipse.ditto.things.model.signals.commands.modify.MergeThingResponse; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; import org.eclipse.ditto.things.model.signals.commands.modify.ThingModifyCommand; import org.eclipse.ditto.things.model.signals.commands.modify.ThingModifyCommandResponse; import org.eclipse.ditto.things.model.signals.commands.query.RetrieveThings; @@ -49,6 +50,7 @@ public class DefaultThingCommandAdapterProvider implements ThingCommandAdapterPr private final ThingQueryCommandResponseAdapter queryCommandResponseAdapter; private final ThingModifyCommandResponseAdapter modifyCommandResponseAdapter; private final ThingMergeCommandResponseAdapter mergeCommandResponseAdapter; + private final ThingMigrateCommandAdapter migrateCommandAdapter; private final ThingSearchCommandAdapter searchCommandAdapter; private final MessageCommandAdapter messageCommandAdapter; private final MessageCommandResponseAdapter messageCommandResponseAdapter; @@ -70,6 +72,7 @@ public DefaultThingCommandAdapterProvider(final ErrorRegistry> getAdapters() { queryCommandResponseAdapter, modifyCommandResponseAdapter, mergeCommandResponseAdapter, + migrateCommandAdapter, messageCommandAdapter, messageCommandResponseAdapter, thingEventAdapter, @@ -132,6 +136,11 @@ public Adapter getMergeCommandAdapter() { return mergeCommandAdapter; } + @Override + public Adapter getMigrateDefinitionCommandAdapter() { + return migrateCommandAdapter; + } + @Override public Adapter> getModifyCommandResponseAdapter() { return modifyCommandResponseAdapter; diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/ThingMigrateCommandAdapter.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/ThingMigrateCommandAdapter.java new file mode 100644 index 0000000000..1e6a365384 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/ThingMigrateCommandAdapter.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 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.protocol.adapter.things; + +import static java.util.Objects.requireNonNull; + +import org.eclipse.ditto.base.model.headers.translator.HeaderTranslator; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.adapter.MigrateDefinitionCommandAdapter; +import org.eclipse.ditto.protocol.mapper.SignalMapperFactory; +import org.eclipse.ditto.protocol.mappingstrategies.MappingStrategiesFactory; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; + + +/** + * Adapter for mapping a {@link MigrateThingDefinition} to and from an {@link Adaptable}. + */ +final class ThingMigrateCommandAdapter extends AbstractThingAdapter +implements MigrateDefinitionCommandAdapter { + + private ThingMigrateCommandAdapter(final HeaderTranslator headerTranslator) { + super(MappingStrategiesFactory.getThingMigrateCommandMappingStrategies(), + SignalMapperFactory.newThingMigrateSignalMapper(), + headerTranslator); + } + + /** + * Returns a new ThingMigrateCommandAdapter. + * + * @param headerTranslator translator between external and Ditto headers. + * @return the adapter. + */ + public static ThingMigrateCommandAdapter of(final HeaderTranslator headerTranslator) { + return new ThingMigrateCommandAdapter(requireNonNull(headerTranslator)); + } + + @Override + protected String getType(final Adaptable adaptable) { + return MigrateThingDefinition.TYPE; + } + +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/AbstractCommandSignalMapper.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/AbstractCommandSignalMapper.java index 10182d5902..ec776eb773 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/AbstractCommandSignalMapper.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/AbstractCommandSignalMapper.java @@ -87,6 +87,9 @@ private void setAction(final CommandsTopicPathBuilder builder, final TopicPath.A case DELETE: builder.delete(); break; + case MIGRATE: + builder.migrate(); + break; default: throw unknownCommandException(action.getName()); } diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/SignalMapperFactory.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/SignalMapperFactory.java index 40f25b25f2..bc478b97f4 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/SignalMapperFactory.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/SignalMapperFactory.java @@ -103,6 +103,10 @@ public static SignalMapper> newThingSearchSignalMapper() { return new ThingSearchSignalMapper<>(); } + public static ThingMigrateSignalMapper newThingMigrateSignalMapper() { + return new ThingMigrateSignalMapper(); + } + public static SignalMapper> newPolicyModifySignalMapper() { return new PolicyModifySignalMapper(); } diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigrateSignalMapper.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigrateSignalMapper.java new file mode 100644 index 0000000000..f87674bde8 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigrateSignalMapper.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 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.protocol.mapper; + +import org.eclipse.ditto.protocol.PayloadBuilder; +import org.eclipse.ditto.protocol.ProtocolFactory; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.protocol.TopicPathBuilder; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; + +/** + * A signal mapper for the {@link MigrateThingDefinition} command. + */ +public final class ThingMigrateSignalMapper extends AbstractCommandSignalMapper{ + + @Override + void enhancePayloadBuilder(final MigrateThingDefinition command, final PayloadBuilder payloadBuilder) { + payloadBuilder.withValue(command.toJson()); + } + + @Override + TopicPathBuilder getTopicPathBuilder(final MigrateThingDefinition command) { + return ProtocolFactory.newTopicPathBuilder(command.getEntityId()).things(); + } + private static final TopicPath.Action[] SUPPORTED_ACTIONS = {TopicPath.Action.MIGRATE}; + + @Override + TopicPath.Action[] getSupportedActions() { + return SUPPORTED_ACTIONS; + } + +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/MappingStrategiesFactory.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/MappingStrategiesFactory.java index 22b7abf4fd..954e2b1e03 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/MappingStrategiesFactory.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/MappingStrategiesFactory.java @@ -59,6 +59,9 @@ public static ThingQueryCommandMappingStrategies getThingQueryCommandMappingStra return ThingQueryCommandMappingStrategies.getInstance(); } + public static ThingMigrateCommandMappingStrategies getThingMigrateCommandMappingStrategies() { + return ThingMigrateCommandMappingStrategies.getInstance(); + } public static RetrieveThingsCommandMappingStrategies getRetrieveThingsCommandMappingStrategies() { return RetrieveThingsCommandMappingStrategies.getInstance(); } diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/ThingMigrateCommandMappingStrategies.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/ThingMigrateCommandMappingStrategies.java new file mode 100644 index 0000000000..a2e128d5bd --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/ThingMigrateCommandMappingStrategies.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 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.protocol.mappingstrategies; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.policies.model.ResourceKey; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.JsonifiableMapper; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; + +/** + * Mapping strategies for the {@code MigrateThingDefinition} command. + */ +public final class ThingMigrateCommandMappingStrategies extends AbstractThingMappingStrategies { + + private static final ThingMigrateCommandMappingStrategies INSTANCE = new ThingMigrateCommandMappingStrategies(); + + private ThingMigrateCommandMappingStrategies() { + super(initMappingStrategies()); + } + + public static ThingMigrateCommandMappingStrategies getInstance() { + return INSTANCE; + } + + private static Map> initMappingStrategies() { + final Map> mappingStrategies = new HashMap<>(); + mappingStrategies.put(MigrateThingDefinition.TYPE, ThingMigrateCommandMappingStrategies::migrateThingDefinition); + return mappingStrategies; + } + + private static MigrateThingDefinition migrateThingDefinition(final Adaptable adaptable) { + JsonObject payloadObject = adaptable.getPayload() + .getValue() + .orElseGet(JsonFactory::newObject) + .asObject(); + ThingId thingId = thingIdFrom(adaptable); + String thingDefinitionUrl = payloadObject.getValueOrThrow(MigrateThingDefinition.JsonFields.JSON_THING_DEFINITION_URL); + final JsonObject migrationPayload = payloadObject.getValue(MigrateThingDefinition.JsonFields.JSON_MIGRATION_PAYLOAD) + .map(JsonValue::asObject).orElse(JsonFactory.newObject()); + final JsonObject patchConditionsJson = payloadObject.getValue(MigrateThingDefinition.JsonFields.JSON_PATCH_CONDITIONS) + .map(JsonValue::asObject).orElse(JsonFactory.newObject()); + Map patchConditions = patchConditionsJson.stream() + .collect(Collectors.toMap( + field -> ResourceKey.newInstance(field.getKey()), + field -> field.getValue().asString() + )); + + Boolean initializeMissingPropertiesFromDefaults = payloadObject.getValueOrThrow(MigrateThingDefinition.JsonFields.JSON_INITIALIZE_MISSING_PROPERTIES_FROM_DEFAULTS); + + return MigrateThingDefinition.of( + thingId, + thingDefinitionUrl, + migrationPayload, + patchConditions, + initializeMissingPropertiesFromDefaults, + dittoHeadersFrom(adaptable) + ); + } +} diff --git a/protocol/src/test/java/org/eclipse/ditto/protocol/LiveTwinTest.java b/protocol/src/test/java/org/eclipse/ditto/protocol/LiveTwinTest.java index 7696c54912..8a85242f54 100644 --- a/protocol/src/test/java/org/eclipse/ditto/protocol/LiveTwinTest.java +++ b/protocol/src/test/java/org/eclipse/ditto/protocol/LiveTwinTest.java @@ -68,6 +68,9 @@ protected TopicPath topicPath(final TopicPath.Action action) { case DELETE: commandsTopicPathBuilder.delete(); break; + case MIGRATE: + commandsTopicPathBuilder.migrate(); + break; } return commandsTopicPathBuilder.build(); } diff --git a/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigrateCommandAdapterTest.java b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigrateCommandAdapterTest.java new file mode 100644 index 0000000000..5c0d8d0c0b --- /dev/null +++ b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigrateCommandAdapterTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 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.protocol.adapter.things; + + +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.LiveTwinTest; +import org.eclipse.ditto.protocol.Payload; +import org.eclipse.ditto.protocol.TestConstants; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.protocol.adapter.DittoProtocolAdapter; +import org.eclipse.ditto.protocol.adapter.ProtocolAdapterTest; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; +import org.junit.Before; +import org.junit.Test; + +/** + * Unit test for {@link ThingMigrateCommandAdapter}. + */ +public final class ThingMigrateCommandAdapterTest extends LiveTwinTest implements ProtocolAdapterTest { + + private ThingMigrateCommandAdapter underTest; + + private TopicPath topicPath; + + @Before + public void setUp() { + underTest = ThingMigrateCommandAdapter.of(DittoProtocolAdapter.getHeaderTranslator()); + topicPath = topicPath(TopicPath.Action.MIGRATE); + } + + @Test + public void migrateThingDefinitionFromAdaptable() { + final ThingId thingId = TestConstants.THING_ID; + final String definitionUrl = "https://example.com/model.tm.jsonld"; + final JsonObject migrationPayload = JsonObject.newBuilder() + .set("attributes", JsonObject.newBuilder().set("foo", "bar").build()) + .build(); + + final MigrateThingDefinition expectedCommand = MigrateThingDefinition.of( + thingId, + definitionUrl, + migrationPayload, + null, + false, + TestConstants.DITTO_HEADERS_V_2 + ); + + final Adaptable adaptable = Adaptable.newBuilder(topicPath) + .withPayload(Payload.newBuilder() + .withValue(expectedCommand.toJson(JsonSchemaVersion.V_2)) + .build()) + .withHeaders(TestConstants.HEADERS_V_2) + .build(); + + final MigrateThingDefinition actualCommand = underTest.fromAdaptable(adaptable); + + assertWithExternalHeadersThat(actualCommand).isEqualTo(expectedCommand); + } + + @Test + public void migrateThingDefinitionToAdaptable() { + final ThingId thingId = TestConstants.THING_ID; + final String definitionUrl = "https://example.com/model.tm.jsonld"; + final JsonObject migrationPayload = JsonObject.newBuilder() + .set("attributes", JsonObject.newBuilder().set("foo", "bar").build()) + .build(); + final MigrateThingDefinition command = MigrateThingDefinition.of( + thingId, + definitionUrl, + migrationPayload, + null, + true, + TestConstants.DITTO_HEADERS_V_2 + ); + + final Adaptable expectedAdaptable = Adaptable.newBuilder(topicPath) + .withPayload(Payload.newBuilder() + .withValue(command.toJson()) + .build()) + .withHeaders(TestConstants.DITTO_HEADERS_V_2) + .build(); + + final Adaptable actualAdaptable = underTest.toAdaptable(command, channel); + + assertWithExternalHeadersThat(actualAdaptable).isEqualTo(expectedAdaptable); + } + + +} diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/exceptions/SkeletonGenerationFailedException.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/exceptions/SkeletonGenerationFailedException.java new file mode 100644 index 0000000000..eb7bb1d880 --- /dev/null +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/exceptions/SkeletonGenerationFailedException.java @@ -0,0 +1,123 @@ +/* + * 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.things.model.signals.commands.exceptions; + +import java.net.URI; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; +import javax.annotation.concurrent.NotThreadSafe; + +import org.eclipse.ditto.base.model.common.HttpStatus; +import org.eclipse.ditto.base.model.exceptions.DittoRuntimeException; +import org.eclipse.ditto.base.model.exceptions.DittoRuntimeExceptionBuilder; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.JsonParsableException; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.things.model.ThingException; +import org.eclipse.ditto.things.model.ThingId; + +/** + * This exception indicates that the skeleton generation for a Thing failed. + */ +@Immutable +@JsonParsableException(errorCode = SkeletonGenerationFailedException.ERROR_CODE) +public final class SkeletonGenerationFailedException extends DittoRuntimeException implements ThingException { + + /** + * Error code of this exception. + */ + public static final String ERROR_CODE = ERROR_CODE_PREFIX + "skeleton.generation.failed"; + + private static final String MESSAGE_TEMPLATE = "Failed to generate a valid skeleton for Thing ID: ''{0}''."; + private static final String DEFAULT_DESCRIPTION = "The provided ThingDefinition could not be used to generate a valid skeleton. Ensure the definition is correct and reachable."; + + + private SkeletonGenerationFailedException(final DittoHeaders dittoHeaders, + @Nullable final String message, + @Nullable final String description, + @Nullable final Throwable cause, + @Nullable final URI href) { + super(ERROR_CODE, HttpStatus.BAD_REQUEST, dittoHeaders, message, description, cause, href); + } + + /** + * A mutable builder for a {@code SkeletonGenerationFailedException}. + * + * @return the builder. + */ + public static Builder newBuilder(final ThingId thingId) { + return new Builder(thingId); + } + + /** + * Constructs a new {@code SkeletonGenerationFailedException} object with given message. + * + * @param message detail message. + * @param dittoHeaders the headers of the command that resulted in this exception. + * @return the new SkeletonGenerationFailedException. + * @throws NullPointerException if {@code dittoHeaders} is {@code null}. + */ + public static SkeletonGenerationFailedException fromMessage(@Nullable final String message, + final DittoHeaders dittoHeaders) { + return DittoRuntimeException.fromMessage(message, dittoHeaders, new Builder()); + } + + /** + * Constructs a new {@code SkeletonGenerationFailedException} object from JSON. + * + * @param jsonObject the JSON with the error message. + * @param dittoHeaders the headers of the command that resulted in this exception. + * @return the new SkeletonGenerationFailedException. + */ + public static SkeletonGenerationFailedException fromJson(final JsonObject jsonObject, + final DittoHeaders dittoHeaders) { + return DittoRuntimeException.fromJson(jsonObject, dittoHeaders, new Builder()); + } + + @Override + public DittoRuntimeException setDittoHeaders(final DittoHeaders dittoHeaders) { + return new Builder() + .message(getMessage()) + .description(getDescription().orElse(null)) + .cause(getCause()) + .href(getHref().orElse(null)) + .dittoHeaders(dittoHeaders) + .build(); + } + + /** + * A mutable builder for {@link SkeletonGenerationFailedException}. + */ + @NotThreadSafe + public static final class Builder extends DittoRuntimeExceptionBuilder { + + private Builder(final ThingId thingId) { + message(MESSAGE_TEMPLATE.replace("''{0}''", thingId.toString())); + description(DEFAULT_DESCRIPTION); + } + + private Builder() { + message(MESSAGE_TEMPLATE); + description(DEFAULT_DESCRIPTION); + } + + @Override + protected SkeletonGenerationFailedException doBuild(final DittoHeaders dittoHeaders, + @Nullable final String message, + @Nullable final String description, + @Nullable final Throwable cause, + @Nullable final URI href) { + return new SkeletonGenerationFailedException(dittoHeaders, message, description, cause, href); + } + } +} diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java index d49597a44a..f9d9c43102 100644 --- a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java @@ -41,7 +41,6 @@ import org.eclipse.ditto.things.model.ThingId; import org.eclipse.ditto.things.model.signals.commands.ThingCommand; import org.eclipse.ditto.things.model.signals.commands.ThingCommandSizeValidator; -import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingIdNotExplicitlySettableException; /** * Command to update a Thing's definition and apply a migration payload. @@ -84,13 +83,13 @@ private MigrateThingDefinition(final ThingId thingId, } /** - * Factory method to create a new {@code UpdateThingDefinition} command. + * Factory method to create a new {@code MigrateThingDefinition} command. * * @param thingId the Thing ID. * @param thingDefinitionUrl the URL of the new Thing definition. * @param migrationPayload the migration payload. * @param patchConditions the patch conditions. - * @param initializeProperties whether to initialize properties. + * @param initializeMissingPropertiesFromDefaults whether to initialize properties. * @param dittoHeaders the Ditto headers. * @return the created {@link MigrateThingDefinition} command. */ @@ -98,24 +97,24 @@ public static MigrateThingDefinition of(final ThingId thingId, final String thingDefinitionUrl, final JsonObject migrationPayload, final Map patchConditions, - final Boolean initializeProperties, + final Boolean initializeMissingPropertiesFromDefaults, final DittoHeaders dittoHeaders) { return new MigrateThingDefinition(thingId, thingDefinitionUrl, migrationPayload, - patchConditions, initializeProperties, dittoHeaders); + patchConditions, initializeMissingPropertiesFromDefaults, dittoHeaders); } /** - * Creates a new {@code UpdateThingDefinition} from a JSON object. + * Creates a new {@code MigrateThingDefinition} from a JSON object. * * @param jsonObject the JSON object from which to create the command. * @param dittoHeaders the Ditto headers. - * @return the created {@code UpdateThingDefinition} command. + * @return the created {@code MigrateThingDefinition} command. */ public static MigrateThingDefinition fromJson(final JsonObject jsonObject, final DittoHeaders dittoHeaders) { final String thingIdStr = jsonObject.getValueOrThrow(ThingCommand.JsonFields.JSON_THING_ID); final String thingDefinitionUrl = jsonObject.getValueOrThrow(JsonFields.JSON_THING_DEFINITION_URL); - final JsonObject migrationPayload = jsonObject.getValueOrThrow(JsonFields.JSON_MIGRATION_PAYLOAD).asObject(); - + final JsonObject migrationPayload = jsonObject.getValue(JsonFields.JSON_MIGRATION_PAYLOAD) + .map(JsonValue::asObject).orElse(JsonFactory.newObject()); final JsonObject patchConditionsJson = jsonObject.getValue(JsonFields.JSON_PATCH_CONDITIONS) .map(JsonValue::asObject).orElse(JsonFactory.newObject()); final Map patchConditions = patchConditionsJson.stream() @@ -124,7 +123,7 @@ public static MigrateThingDefinition fromJson(final JsonObject jsonObject, final field -> field.getValue().asString() )); - final Boolean initializeProperties = jsonObject.getValue(JsonFields.JSON_INITIALIZE_MISSING_PROPERTIES_FROM_DEFAULTS) + final Boolean initializeMissingPropertiesFromDefaults = jsonObject.getValue(JsonFields.JSON_INITIALIZE_MISSING_PROPERTIES_FROM_DEFAULTS) .orElse(Boolean.FALSE); return new MigrateThingDefinition( @@ -132,22 +131,10 @@ public static MigrateThingDefinition fromJson(final JsonObject jsonObject, final thingDefinitionUrl, migrationPayload, patchConditions, - initializeProperties, + initializeMissingPropertiesFromDefaults, dittoHeaders); } - /** - * Creates a command for updating the definition - * - * @param thingId the thing id. - * @param dittoHeaders the ditto headers. - * @return the created {@link MigrateThingDefinition} command. - */ - public static MigrateThingDefinition withThing(final ThingId thingId, final JsonObject jsonObject, final DittoHeaders dittoHeaders) { - ensureThingIdMatches(thingId, jsonObject); - return fromJson(jsonObject, dittoHeaders); - } - @Override public ThingId getEntityId() { return thingId; @@ -223,19 +210,6 @@ protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, } } - - /** - * Ensures that the thingId is consistent with the id of the thing. - * - * @throws org.eclipse.ditto.things.model.signals.commands.exceptions.ThingIdNotExplicitlySettableException if ids do not match. - */ - private static void ensureThingIdMatches(final ThingId thingId, final JsonObject jsonObject) { - if (!jsonObject.getValueOrThrow(ThingCommand.JsonFields.JSON_THING_ID).equals(thingId.toString())) { - throw ThingIdNotExplicitlySettableException.forDittoProtocol().build(); - } - } - - @Override public JsonSchemaVersion[] getSupportedSchemaVersions() { return new JsonSchemaVersion[]{JsonSchemaVersion.V_2}; @@ -257,21 +231,21 @@ private JsonObject checkJsonSize(final JsonObject value, final DittoHeaders ditt } /** - * An enumeration of the JSON fields of an {@code UpdateThingDefinition} command. + * An enumeration of the JSON fields of an {@code MigrateThingDefinition} command. */ @Immutable - static final class JsonFields { + public static final class JsonFields { - static final JsonFieldDefinition JSON_THING_DEFINITION_URL = + public static final JsonFieldDefinition JSON_THING_DEFINITION_URL = JsonFactory.newStringFieldDefinition("thingDefinitionUrl", FieldType.REGULAR, JsonSchemaVersion.V_2); - static final JsonFieldDefinition JSON_MIGRATION_PAYLOAD = + public static final JsonFieldDefinition JSON_MIGRATION_PAYLOAD = JsonFactory.newJsonObjectFieldDefinition("migrationPayload", FieldType.REGULAR, JsonSchemaVersion.V_2); - static final JsonFieldDefinition JSON_PATCH_CONDITIONS = + public static final JsonFieldDefinition JSON_PATCH_CONDITIONS = JsonFactory.newJsonObjectFieldDefinition("patchConditions", FieldType.REGULAR, JsonSchemaVersion.V_2); - static final JsonFieldDefinition JSON_INITIALIZE_MISSING_PROPERTIES_FROM_DEFAULTS = + public static final JsonFieldDefinition JSON_INITIALIZE_MISSING_PROPERTIES_FROM_DEFAULTS = JsonFactory.newBooleanFieldDefinition("initializeMissingPropertiesFromDefaults", FieldType.REGULAR, JsonSchemaVersion.V_2); private JsonFields() { @@ -300,7 +274,7 @@ public int hashCode() { @Override public String toString() { - return "UpdateThingDefinition{" + + return "MigrateThingDefinition{" + "thingId=" + thingId + ", thingDefinitionUrl='" + thingDefinitionUrl + '\'' + ", migrationPayload=" + migrationPayload + diff --git a/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java index df4d4f453c..a81ea7a1a9 100644 --- a/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java +++ b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java @@ -134,19 +134,6 @@ public void testFromJson() { assertThat(command.isInitializeMissingPropertiesFromDefaults()).isTrue(); } - @Test(expected = ThingIdNotExplicitlySettableException.class) - public void testEnsureThingIdMismatchThrowsException() { - final JsonObject json = JsonFactory.newObjectBuilder() - .set("thingId", "another-thing") - .set("thingDefinitionUrl", "https://models.example.com/thing-definition-1.0.0.tm.jsonld") - .set("migrationPayload", MIGRATION_PAYLOAD) - .set("patchConditions", JsonFactory.newObjectBuilder().build()) - .set("initializeProperties", false) - .build(); - - MigrateThingDefinition.withThing(THING_ID, json, DITTO_HEADERS); - } - @Test public void toJsonReturnsExpected() { final MigrateThingDefinition command = MigrateThingDefinition.of( diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java index 3a1d6f9e5c..216c3a2f8c 100644 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Contributors to the Eclipse Foundation + * Copyright (c) 2024 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -15,18 +15,21 @@ import java.time.Instant; import java.util.Map; import java.util.Optional; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import org.apache.pekko.actor.ActorSystem; +import org.apache.pekko.japi.Pair; import org.eclipse.ditto.base.model.entity.metadata.Metadata; +import org.eclipse.ditto.base.model.exceptions.InvalidRqlExpressionException; import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.headers.WithDittoHeaders; import org.eclipse.ditto.base.model.headers.entitytag.EntityTag; import org.eclipse.ditto.base.model.json.FieldType; import org.eclipse.ditto.internal.utils.persistentactors.results.Result; +import org.eclipse.ditto.internal.utils.persistentactors.results.ResultFactory; import org.eclipse.ditto.json.JsonFactory; import org.eclipse.ditto.json.JsonObject; import org.eclipse.ditto.json.JsonObjectBuilder; @@ -34,6 +37,7 @@ import org.eclipse.ditto.placeholders.PlaceholderFactory; import org.eclipse.ditto.placeholders.TimePlaceholder; import org.eclipse.ditto.policies.model.ResourceKey; +import org.eclipse.ditto.rql.model.ParserException; import org.eclipse.ditto.rql.parser.RqlPredicateParser; import org.eclipse.ditto.rql.query.filter.QueryFilterCriteriaFactory; import org.eclipse.ditto.rql.query.things.ThingPredicateVisitor; @@ -42,30 +46,34 @@ import org.eclipse.ditto.things.model.ThingsModelFactory; import org.eclipse.ditto.things.model.signals.commands.ThingCommandSizeValidator; import org.eclipse.ditto.things.model.signals.commands.ThingResourceMapper; -import org.eclipse.ditto.things.model.signals.commands.modify.MergeThing; +import org.eclipse.ditto.things.model.signals.commands.exceptions.SkeletonGenerationFailedException; +import org.eclipse.ditto.things.model.signals.commands.modify.MergeThingResponse; import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; import org.eclipse.ditto.things.model.signals.events.ThingEvent; +import org.eclipse.ditto.things.model.signals.events.ThingMerged; /** * Strategy to handle the {@link MigrateThingDefinition} command. - * + *

* This strategy processes updates to a Thing's definition, applying necessary data migrations * and ensuring that defaults are properly initialized when required. * - * Assumptions: - * - The {@link MigrateThingDefinition} command provides a ThingDefinition URL, which is used - * to create a skeleton Thing. The command's payload also includes migration data and patch - * conditions for fine-grained updates. - * - Patch conditions are evaluated using RQL-based expressions to determine which migration - * payload entries should be applied. - * - Skeleton generation extracts and merges Thing definitions and default values separately, - * ensuring a clear distinction between structural updates and default settings. - * - After applying skeleton-based modifications and migration payloads, the changes are merged - * - Property initialization can be optionally enabled via the command, applying default values - * to the updated Thing when set to true. - * - The resulting Thing undergoes validation to ensure compliance with WoT model constraints - * before persisting changes. + *

Assumptions:

+ *
    + *
  • The {@link MigrateThingDefinition} command provides a ThingDefinition URL, which is used + * to create a skeleton Thing. The command's payload also includes migration data and patch + * conditions for fine-grained updates.
  • + *
  • Patch conditions are evaluated using RQL-based expressions to determine which migration + * payload entries should be applied.
  • + *
  • Skeleton generation extracts and merges Thing definitions and default values separately, + * ensuring a clear distinction between structural updates and default settings.
  • + *
  • After applying skeleton-based modifications and migration payloads, the changes are merged.
  • + *
  • Property initialization can be optionally enabled via the command, applying default values + * to the updated Thing when set to true.
  • + *
  • The resulting Thing undergoes validation to ensure compliance with WoT model constraints + * before persisting changes.
  • + *
*/ @Immutable public final class MigrateThingDefinitionStrategy extends AbstractThingModifyCommandStrategy { @@ -75,8 +83,12 @@ public final class MigrateThingDefinitionStrategy extends AbstractThingModifyCom private static final TimePlaceholder TIME_PLACEHOLDER = TimePlaceholder.getInstance(); - - public MigrateThingDefinitionStrategy(final ActorSystem actorSystem) { + /** + * Constructs a new {@code MigrateThingDefinitionStrategy} object. + * + * @param actorSystem the actor system to use for loading the WoT extension. + */ + MigrateThingDefinitionStrategy(final ActorSystem actorSystem) { super(MigrateThingDefinition.class, actorSystem); } @@ -89,13 +101,11 @@ protected Result> doApply(final Context context, final Thing existingThing = getEntityOrThrow(thing); final Instant eventTs = getEventTimestamp(); - - return handleUpdateDefinition(context, existingThing, eventTs, nextRevision, command, metadata) - .toCompletableFuture() - .join(); + return handleMigrateDefinition(context, existingThing, eventTs, nextRevision, command, metadata); } - private CompletionStage>> handleUpdateDefinition(final Context context, + private Result> handleMigrateDefinition( + final Context context, final Thing existingThing, final Instant eventTs, final long nextRevision, @@ -103,6 +113,7 @@ private CompletionStage>> handleUpdateDefinition(final Cont @Nullable final Metadata metadata) { final DittoHeaders dittoHeaders = command.getDittoHeaders(); + final JsonPointer path = JsonPointer.empty(); // 1. Evaluate Patch Conditions and modify the migrationPayload final JsonObject adjustedMigrationPayload = evaluatePatchConditions( @@ -111,38 +122,28 @@ private CompletionStage>> handleUpdateDefinition(final Cont command.getPatchConditions(), dittoHeaders); - // 2. Generate Skeleton using definition - final CompletionStage skeletonStage = generateSkeleton(context, command, dittoHeaders); + // 2. Generate Skeleton using definition and apply migration + final CompletionStage updatedThingStage = generateSkeleton(command, dittoHeaders) + .thenApply(skeleton -> mergeSkeletonWithThing( + existingThing, skeleton, command.getThingDefinitionUrl(), + command.isInitializeMissingPropertiesFromDefaults())) + .thenApply(mergedThing -> applyMigrationPayload(context, + mergedThing, adjustedMigrationPayload, dittoHeaders, nextRevision, eventTs)); - // 3. Merge Skeleton with Existing Thing and Apply Migration Payload - final CompletionStage updatedThingStage = skeletonStage.thenApply(skeleton -> { - final Thing mergedThing = mergeSkeletonWithThing(existingThing, skeleton, command.getThingDefinitionUrl(), command.isInitializeMissingPropertiesFromDefaults()); - return applyMigrationPayload(mergedThing, adjustedMigrationPayload, dittoHeaders, nextRevision, eventTs); - }); + // 3. Validate and build event response + final CompletionStage> validatedStage = updatedThingStage + .thenCompose(mergedThing -> buildValidatedStage(command, existingThing, mergedThing) + .thenApply(migrateThingDefinition -> new Pair<>(mergedThing, migrateThingDefinition))); + + final CompletionStage> eventStage = validatedStage.thenApply(pair -> ThingMerged.of( + pair.second().getEntityId(), path, pair.first().toJson(), nextRevision, eventTs, dittoHeaders, + metadata)); - // 4. Validate and Build Result - return updatedThingStage.thenCompose(updatedThing -> - buildValidatedStage(command, existingThing, updatedThing) - .thenCompose(validatedCommand -> { - final MergeThing mergeThingCommand = MergeThing.of( - command.getEntityId(), - JsonPointer.empty(), - updatedThing.toJson(), - dittoHeaders - ); - - final MergeThingStrategy mergeStrategy = new MergeThingStrategy(context.getActorSystem()); - final Result> mergeResult = mergeStrategy.doApply( - context, - existingThing, - nextRevision, - mergeThingCommand, - metadata - ); - - return CompletableFuture.completedFuture(mergeResult); - }) - ); + final CompletionStage responseStage = validatedStage.thenApply(pair -> + appendETagHeaderIfProvided(command, MergeThingResponse.of(command.getEntityId(), path, dittoHeaders), + pair.first())); + + return ResultFactory.newMutationResult(command, eventStage, responseStage); } private JsonObject evaluatePatchConditions(final Thing existingThing, @@ -151,7 +152,7 @@ private JsonObject evaluatePatchConditions(final Thing existingThing, final DittoHeaders dittoHeaders) { final JsonObjectBuilder adjustedPayloadBuilder = migrationPayload.toBuilder(); - for (Map.Entry entry : patchConditions.entrySet()) { + for (final Map.Entry entry : patchConditions.entrySet()) { final ResourceKey resourceKey = entry.getKey(); final String conditionExpression = entry.getValue(); @@ -165,9 +166,12 @@ private JsonObject evaluatePatchConditions(final Thing existingThing, return adjustedPayloadBuilder.build(); } - public boolean doesMigrationPayloadContainResourceKey(JsonObject migrationPayload, JsonPointer pointer) { + + private static boolean doesMigrationPayloadContainResourceKey(final JsonObject migrationPayload, + final JsonPointer pointer) { return migrationPayload.getValue(pointer).isPresent(); } + private boolean evaluateCondition(final Thing existingThing, final String conditionExpression, final DittoHeaders dittoHeaders) { @@ -180,12 +184,16 @@ private boolean evaluateCondition(final Thing existingThing, PlaceholderFactory.newPlaceholderResolver(TIME_PLACEHOLDER, new Object())); return predicate.test(existingThing); - } catch (Exception e) { - return false; + } catch (final ParserException | IllegalArgumentException e) { + throw InvalidRqlExpressionException.newBuilder() + .message(e.getMessage()) + .cause(e) + .dittoHeaders(dittoHeaders) + .build(); } } - private CompletionStage generateSkeleton(final Context context, + private CompletionStage generateSkeleton( final MigrateThingDefinition command, final DittoHeaders dittoHeaders) { return wotThingSkeletonGenerator.provideThingSkeletonForCreation( @@ -193,25 +201,29 @@ private CompletionStage generateSkeleton(final Context context, ThingsModelFactory.newDefinition(command.getThingDefinitionUrl()), dittoHeaders ) - .thenApply(optionalSkeleton -> optionalSkeleton.orElseThrow()); + .thenApply(optionalSkeleton -> optionalSkeleton.orElseThrow(() -> + SkeletonGenerationFailedException.newBuilder(command.getEntityId()) + .dittoHeaders(command.getDittoHeaders()) + .build() + )); } private Thing extractDefinitions(final Thing thing, final String thingDefinitionUrl) { var thingBuilder = ThingsModelFactory.newThingBuilder(); thingBuilder.setDefinition(ThingsModelFactory.newDefinition(thingDefinitionUrl)); - thing.getFeatures().orElseGet(null).forEach(feature-> { + thing.getFeatures().orElseGet(ThingsModelFactory::emptyFeatures).forEach(feature -> { thingBuilder.setFeature(feature.getId(), feature.getDefinition().get(), null); }); return thingBuilder.build(); } - private Thing extractDefaultValues(Thing thing) { + private Thing extractDefaultValues(final Thing thing) { var thingBuilder = ThingsModelFactory.newThingBuilder(); - thingBuilder.setAttributes(thing.getAttributes().get()); - thing.getFeatures().orElseGet(null).forEach(feature-> { - thingBuilder.setFeature(feature.getId(), feature.getProperties().get()); + thingBuilder.setAttributes(thing.getAttributes().orElse(ThingsModelFactory.emptyAttributes())); + thing.getFeatures().orElseGet(ThingsModelFactory::emptyFeatures).forEach(feature -> { + thingBuilder.setFeature(feature.getId(), feature.getDefinition().orElse(null), null); }); return thingBuilder.build(); } @@ -221,10 +233,10 @@ private Thing mergeSkeletonWithThing(final Thing existingThing, final Thing skel final String thingDefinitionUrl, final boolean isInitializeProperties) { // Extract definitions and convert to JSON - var fullThingDefinitions = extractDefinitions(skeletonThing, thingDefinitionUrl).toJson(); + final var fullThingDefinitions = extractDefinitions(skeletonThing, thingDefinitionUrl).toJson(); // Merge the extracted definitions with the existing thing JSON - var mergedThingJson = JsonFactory.mergeJsonValues(fullThingDefinitions, existingThing.toJson()).asObject(); + final var mergedThingJson = JsonFactory.mergeJsonValues(fullThingDefinitions, existingThing.toJson()).asObject(); // If not initializing properties, return the merged result if (!isInitializeProperties) { @@ -236,16 +248,15 @@ private Thing mergeSkeletonWithThing(final Thing existingThing, final Thing skel mergedThingJson, extractDefaultValues(skeletonThing).toJson()).asObject()); } - private Thing applyMigrationPayload(final Thing thing, + private Thing applyMigrationPayload(final Context context, final Thing thing, final JsonObject migrationPayload, final DittoHeaders dittoHeaders, final long nextRevision, final Instant eventTs) { final JsonObject thingJson = thing.toJson(FieldType.all()); + final JsonObject mergedJson = JsonFactory.newObject(migrationPayload, thingJson); - final JsonObject mergePatch = JsonFactory.newObject(JsonPointer.empty(), migrationPayload); - final JsonObject mergedJson = JsonFactory.mergeJsonValues(mergePatch, thingJson).asObject(); - + context.getLog().debug("Thing updated from migrated JSON: {}", mergedJson); ThingCommandSizeValidator.getInstance().ensureValidSize( mergedJson::getUpperBoundForStringSize, () -> mergedJson.toString().length(), @@ -258,7 +269,8 @@ private Thing applyMigrationPayload(final Thing thing, } @Override - public Optional previousEntityTag(final MigrateThingDefinition command, @Nullable final Thing previousEntity) { + public Optional previousEntityTag(final MigrateThingDefinition command, + @Nullable final Thing previousEntity) { return ENTITY_TAG_MAPPER.map(JsonPointer.empty(), previousEntity); } @@ -269,7 +281,7 @@ public Optional nextEntityTag(final MigrateThingDefinition command, @ @Override protected CompletionStage performWotValidation(final MigrateThingDefinition command, - @Nullable final Thing previousThing, @Nullable final Thing previewThing) { + @Nullable final Thing previousThing, @Nullable final Thing previewThing) { return wotThingModelValidator.validateThing( Optional.ofNullable(previewThing).orElseThrow(), command.getResourcePath(), diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingCommandStrategies.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingCommandStrategies.java index 266e4753f6..3187dbf64a 100644 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingCommandStrategies.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingCommandStrategies.java @@ -87,6 +87,7 @@ private void addThingStrategies(final ActorSystem system) { addStrategy(new RetrieveThingStrategy(system)); addStrategy(new DeleteThingStrategy(system)); addStrategy(new MergeThingStrategy(system)); + addStrategy(new MigrateThingDefinitionStrategy(system)); } private void addPolicyStrategies(final ActorSystem system) { @@ -107,7 +108,6 @@ private void addDefinitionStrategies(final ActorSystem system) { addStrategy(new ModifyThingDefinitionStrategy(system)); addStrategy(new RetrieveThingDefinitionStrategy(system)); addStrategy(new DeleteThingDefinitionStrategy(system)); - addStrategy(new MigrateThingDefinitionStrategy(system)); } private void addFeaturesStrategies(final ActorSystem system) { From 4c6e250c0eadfb1bacc6407d69d3f7977da7b18c Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Tue, 4 Feb 2025 01:34:57 +0100 Subject: [PATCH 04/15] fix license --- .../commands/exceptions/SkeletonGenerationFailedException.java | 2 +- .../strategies/commands/MigrateThingDefinitionStrategy.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/exceptions/SkeletonGenerationFailedException.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/exceptions/SkeletonGenerationFailedException.java index eb7bb1d880..a312e94ae2 100644 --- a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/exceptions/SkeletonGenerationFailedException.java +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/exceptions/SkeletonGenerationFailedException.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Contributors to the Eclipse Foundation + * Copyright (c) 2025 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java index 216c3a2f8c..bca4686b0e 100644 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Contributors to the Eclipse Foundation + * Copyright (c) 2025 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. From 4f9be6badead1989648bd5ea83c33cb4a616c5cb Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Thu, 6 Feb 2025 20:49:33 +0100 Subject: [PATCH 05/15] add migrated command and dry-run logic --- .../paths/things/migrateDefinition.yml | 23 +- .../things/migrateThingDefinitionResponse.yml | 41 +++ .../endpoints/routes/things/ThingsRoute.java | 29 +- .../protocol/EventsTopicPathBuilder.java | 8 + .../ditto/protocol/ImmutableTopicPath.java | 6 + .../org/eclipse/ditto/protocol/TopicPath.java | 6 +- .../adapter/AdapterResolverBySignal.java | 5 + .../adapter/MigratedEventAdapter.java | 43 +++ .../provider/MigrateEventAdapterProvider.java | 30 ++ .../provider/ThingCommandAdapterProvider.java | 1 + .../DefaultThingCommandAdapterProvider.java | 7 + .../things/ThingMigratedEventAdapter.java | 52 ++++ .../protocol/mapper/SignalMapperFactory.java | 5 + .../ThingMigratedEventSignalMapper.java | 59 ++++ .../MappingStrategiesFactory.java | 4 + .../ThingMigratedEventMappingStrategies.java | 75 +++++ .../things/ThingMigratedEventAdapterTest.java | 141 +++++++++ .../modify/MigrateThingDefinition.java | 11 +- .../MigrateThingDefinitionResponse.java | 281 ++++++++++++++++++ .../events/ThingEventToThingConverter.java | 11 + .../model/signals/events/ThingMigrated.java | 222 ++++++++++++++ .../MigrateThingDefinitionResponseTest.java | 108 +++++++ .../signals/events/ThingMigratedTest.java | 91 ++++++ .../MigrateThingDefinitionStrategy.java | 146 ++++++--- .../events/ThingEventStrategies.java | 2 + .../events/ThingMigratedStrategy.java | 54 ++++ .../persistence/actors/ETagTestUtils.java | 15 + .../commands/AbstractCommandStrategyTest.java | 8 +- .../MigrateThingDefinitionStrategyTest.java | 147 +++++++++ 29 files changed, 1569 insertions(+), 62 deletions(-) create mode 100644 documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionResponse.yml create mode 100644 protocol/src/main/java/org/eclipse/ditto/protocol/adapter/MigratedEventAdapter.java create mode 100644 protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/MigrateEventAdapterProvider.java create mode 100644 protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapter.java create mode 100644 protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigratedEventSignalMapper.java create mode 100644 protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/ThingMigratedEventMappingStrategies.java create mode 100644 protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapterTest.java create mode 100644 things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionResponse.java create mode 100644 things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java create mode 100644 things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionResponseTest.java create mode 100644 things/model/src/test/java/org/eclipse/ditto/things/model/signals/events/ThingMigratedTest.java create mode 100644 things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingMigratedStrategy.java create mode 100644 things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java diff --git a/documentation/src/main/resources/openapi/sources/paths/things/migrateDefinition.yml b/documentation/src/main/resources/openapi/sources/paths/things/migrateDefinition.yml index 043588169b..bf920f0659 100644 --- a/documentation/src/main/resources/openapi/sources/paths/things/migrateDefinition.yml +++ b/documentation/src/main/resources/openapi/sources/paths/things/migrateDefinition.yml @@ -19,6 +19,8 @@ post: - Patch conditions to ensure consistent updates. - Whether properties should be initialized if missing. + If the `dry-run` query parameter is set to `true`, the request will return the calculated migration result without applying any changes. + ### Example: ```json { @@ -50,6 +52,13 @@ post: - Things parameters: - $ref: '../../parameters/thingIdPathParam.yml' + - name: dry-run + in: query + description: If set to `true`, performs a dry-run and returns the migration result without applying changes. + required: false + schema: + type: boolean + default: false requestBody: description: JSON payload containing the new definition URL, migration payload, patch conditions, and initialization flag. required: true @@ -58,8 +67,18 @@ post: schema: $ref: '../../schemas/things/migrateThingDefinitionRequest.yml' responses: - '204': - description: The thing definition was successfully updated. No content is returned. + '200': + description: The thing definition was successfully updated, and the updated Thing is returned. + content: + application/json: + schema: + $ref: '../../schemas/things/migrateThingDefinitionResponse.yml' + '202': + description: Dry-run successful. The migration result is returned without applying changes. + content: + application/json: + schema: + $ref: '../../schemas/things/migrateThingDefinitionResponse.yml' '400': description: The request could not be processed due to invalid input. content: diff --git a/documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionResponse.yml b/documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionResponse.yml new file mode 100644 index 0000000000..c78eb9a9d2 --- /dev/null +++ b/documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionResponse.yml @@ -0,0 +1,41 @@ +# Copyright (c) 2025 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 +type: object +description: Response payload after applying or simulating a migration to a Thing. + +properties: + thingId: + type: string + description: Unique identifier representing the migrated Thing. + patch: + type: object + description: The patch containing updates to the Thing. + properties: + definition: + $ref: 'definition.yml' + attributes: + $ref: 'attributes.yml' + features: + $ref: '../features/features.yml' + + mergeStatus: + type: string + description: | + Indicates the result of the migration process. + - `APPLIED`: The migration was successfully applied. + - `DRY_RUN`: The migration result was calculated but not applied. + enum: [APPLIED, DRY_RUN] + example: "APPLIED" + +required: + - thingId + - patch + - mergeStatus diff --git a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java index 600953e7bb..52d369aef4 100755 --- a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java +++ b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java @@ -585,24 +585,27 @@ private Route thingsEntryMigrateDefinition(final RequestContext ctx, final ThingId thingId) { return rawPathPrefix(PathMatchers.slash().concat(PATH_MIGRATE_DEFINITION), () -> pathEndOrSingleSlash(() -> - // POST /things//migrateDefinition - post(() -> ensureMediaTypeJsonWithFallbacksThenExtractDataBytes(ctx, dittoHeaders, - payloadSource -> handlePerRequest(ctx, dittoHeaders, payloadSource, - payload -> { - final JsonObject inputJson = - wrapJsonRuntimeException(() -> JsonFactory.newObject(payload)); - final JsonObject updatedJson = addThingId(inputJson, thingId); - - final MigrateThingDefinition migrateThingDefinitionCommand = - MigrateThingDefinition.fromJson(updatedJson, dittoHeaders); - return migrateThingDefinitionCommand; - }) + // POST /things//migrateDefinition?dry-run=false + parameterOptional("dry-run", dryRun -> + ensureMediaTypeJsonWithFallbacksThenExtractDataBytes(ctx, dittoHeaders, + payloadSource -> handlePerRequest(ctx, dittoHeaders, payloadSource, payload -> { + final JsonObject inputJson = + wrapJsonRuntimeException(() -> JsonFactory.newObject(payload)); + final JsonObject updatedJson = addThingIdAndDryRun(inputJson, thingId, isDryRun(dryRun)); + return MigrateThingDefinition.fromJson(updatedJson, dittoHeaders.toBuilder() + .putHeader(DittoHeaderDefinition.DRY_RUN.getKey(),dryRun.orElse(Boolean.FALSE.toString())) + .build()); + }) ) )) ); } + private static boolean isDryRun(final Optional dryRun) { + return dryRun.map(Boolean::valueOf).orElse(Boolean.FALSE); + } - private static JsonObject addThingId(final JsonObject inputJson, final ThingId thingId) { + private static JsonObject addThingIdAndDryRun(final JsonObject inputJson, final ThingId thingId, + final boolean dryRun) { checkNotNull(inputJson, "inputJson"); return JsonFactory.newObjectBuilder(inputJson) .set(ThingCommand.JsonFields.JSON_THING_ID, thingId.toString()) diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/EventsTopicPathBuilder.java b/protocol/src/main/java/org/eclipse/ditto/protocol/EventsTopicPathBuilder.java index b4d7bbc806..f38f8e5b1c 100755 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/EventsTopicPathBuilder.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/EventsTopicPathBuilder.java @@ -46,4 +46,12 @@ public interface EventsTopicPathBuilder extends TopicPathBuildable { * @return this builder to allow method chaining. */ EventsTopicPathBuilder deleted(); + + /** + * Sets the {@code Action} of this builder to {@link TopicPath.Action#MIGRATED}. A previously set action is + * replaced. + * + * @return this builder to allow method chaining. + */ + EventsTopicPathBuilder migrated(); } diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/ImmutableTopicPath.java b/protocol/src/main/java/org/eclipse/ditto/protocol/ImmutableTopicPath.java index c66997792d..9117c84a7a 100755 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/ImmutableTopicPath.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/ImmutableTopicPath.java @@ -509,6 +509,12 @@ public EventsTopicPathBuilder deleted() { return this; } + @Override + public EventsTopicPathBuilder migrated() { + action = Action.MIGRATED; + return this; + } + @Override public MessagesTopicPathBuilder subject(final String subject) { this.subject = checkNotNull(subject, "subject"); diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPath.java b/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPath.java index 3880b15be6..4ad99a8670 100755 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPath.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPath.java @@ -407,16 +407,18 @@ enum Action { DELETE("delete"), + MIGRATE("migrate"), CREATED("created"), MODIFIED("modified"), MERGED("merged"), + MIGRATED("migrated"), - DELETED("deleted"), - MIGRATE("migrate"); + DELETED("deleted"); + private final String name; diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/AdapterResolverBySignal.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/AdapterResolverBySignal.java index 9c85df64f0..b77c838d2e 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/AdapterResolverBySignal.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/AdapterResolverBySignal.java @@ -55,6 +55,7 @@ import org.eclipse.ditto.things.model.signals.commands.query.ThingQueryCommandResponse; import org.eclipse.ditto.things.model.signals.events.ThingEvent; import org.eclipse.ditto.things.model.signals.events.ThingMerged; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; import org.eclipse.ditto.thingsearch.model.signals.commands.SearchErrorResponse; import org.eclipse.ditto.thingsearch.model.signals.commands.ThingSearchCommand; import org.eclipse.ditto.thingsearch.model.signals.events.SubscriptionEvent; @@ -116,6 +117,10 @@ private > Adapter resolveEvent(final Event event, fina validateChannel(channel, event, LIVE, TWIN); return (Adapter) thingsAdapters.getMergedEventAdapter(); } + if (event instanceof ThingMigrated) { + validateChannel(channel, event, LIVE, TWIN); + return (Adapter) thingsAdapters.getMigratedEventAdapter(); + } if (event instanceof ThingEvent) { validateChannel(channel, event, LIVE, TWIN); return (Adapter) thingsAdapters.getEventAdapter(); diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/MigratedEventAdapter.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/MigratedEventAdapter.java new file mode 100644 index 0000000000..94208d82e2 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/MigratedEventAdapter.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 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.protocol.adapter; + +import java.util.EnumSet; +import java.util.Set; + +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; + +/** + * An {@code Adapter} maps objects of type {@link ThingMigrated} to an {@link org.eclipse.ditto.protocol.Adaptable} and + * vice versa. + * + * @since 3.7.0 + */ +public interface MigratedEventAdapter extends Adapter { + + @Override + default Set getCriteria() { + return EnumSet.of(TopicPath.Criterion.EVENTS); + } + + @Override + default Set getActions() { + return EnumSet.of(TopicPath.Action.MIGRATED); + } + + @Override + default boolean isForResponses() { + return false; + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/MigrateEventAdapterProvider.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/MigrateEventAdapterProvider.java new file mode 100644 index 0000000000..ba1ef6afc0 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/MigrateEventAdapterProvider.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 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.protocol.adapter.provider; + +import org.eclipse.ditto.protocol.adapter.Adapter; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; + +/** + * Interface providing the merged event adapter. + * + * @since 3.7.0 + */ +interface MigrateEventAdapterProvider { + + /** + * @return the migrate event adapter + */ + Adapter getMigratedEventAdapter(); + +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/ThingCommandAdapterProvider.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/ThingCommandAdapterProvider.java index fb2a360c26..9005254e44 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/ThingCommandAdapterProvider.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/provider/ThingCommandAdapterProvider.java @@ -36,6 +36,7 @@ public interface ThingCommandAdapterProvider SearchErrorResponseAdapterProvider, EventAdapterProvider>, MergeEventAdapterProvider, + MigrateEventAdapterProvider, SubscriptionEventAdapterProvider>, ThingSearchCommandAdapterProvider>, MigrateDefinitionCommandAdapterProvider, diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/DefaultThingCommandAdapterProvider.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/DefaultThingCommandAdapterProvider.java index c1a77eb79b..370e33aff4 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/DefaultThingCommandAdapterProvider.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/DefaultThingCommandAdapterProvider.java @@ -34,6 +34,7 @@ import org.eclipse.ditto.things.model.signals.commands.query.ThingQueryCommandResponse; import org.eclipse.ditto.things.model.signals.events.ThingEvent; import org.eclipse.ditto.things.model.signals.events.ThingMerged; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; import org.eclipse.ditto.thingsearch.model.signals.commands.SearchErrorResponse; import org.eclipse.ditto.thingsearch.model.signals.commands.ThingSearchCommand; import org.eclipse.ditto.thingsearch.model.signals.events.SubscriptionEvent; @@ -56,6 +57,7 @@ public class DefaultThingCommandAdapterProvider implements ThingCommandAdapterPr private final MessageCommandResponseAdapter messageCommandResponseAdapter; private final ThingEventAdapter thingEventAdapter; private final ThingMergedEventAdapter thingMergedEventAdapter; + private final ThingMigratedEventAdapter thingMigratedEventAdapter; private final SubscriptionEventAdapter subscriptionEventAdapter; private final ThingErrorResponseAdapter errorResponseAdapter; private final RetrieveThingsCommandAdapter retrieveThingsCommandAdapter; @@ -78,6 +80,7 @@ public DefaultThingCommandAdapterProvider(final ErrorRegistry> getAdapters() { messageCommandResponseAdapter, thingEventAdapter, thingMergedEventAdapter, + thingMigratedEventAdapter, searchCommandAdapter, subscriptionEventAdapter, errorResponseAdapter, @@ -120,6 +124,9 @@ public Adapter> getEventAdapter() { public Adapter getMergedEventAdapter() { return thingMergedEventAdapter; } + public Adapter getMigratedEventAdapter() { + return thingMigratedEventAdapter; + } @Override public Adapter> getSubscriptionEventAdapter() { diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapter.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapter.java new file mode 100644 index 0000000000..31ed7d5233 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapter.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 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.protocol.adapter.things; + +import static java.util.Objects.requireNonNull; + +import org.eclipse.ditto.base.model.headers.translator.HeaderTranslator; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.adapter.MigratedEventAdapter; +import org.eclipse.ditto.protocol.mapper.SignalMapperFactory; +import org.eclipse.ditto.protocol.mappingstrategies.MappingStrategiesFactory; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; + +/** + * Adapter for mapping a {@link org.eclipse.ditto.things.model.signals.events.ThingMigrated} to and from an + * {@link org.eclipse.ditto.protocol.Adaptable}. + */ +final class ThingMigratedEventAdapter extends AbstractThingAdapter implements MigratedEventAdapter { + + private ThingMigratedEventAdapter(final HeaderTranslator headerTranslator) { + super(MappingStrategiesFactory.getThingMigratedEventMappingStrategies(), + SignalMapperFactory.newThingMigratedEventSignalMapper(), + headerTranslator); + } + + /** + * Returns a new ThingMigratedEventAdapter. + * + * @param headerTranslator translator between external and Ditto headers. + * @return the adapter. + */ + public static ThingMigratedEventAdapter of(final HeaderTranslator headerTranslator) { + return new ThingMigratedEventAdapter(requireNonNull(headerTranslator)); + } + + @Override + protected String getType(final Adaptable adaptable) { + final JsonPointer path = adaptable.getPayload().getPath(); + return payloadPathMatcher.match(path); + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/SignalMapperFactory.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/SignalMapperFactory.java index bc478b97f4..776b25af0d 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/SignalMapperFactory.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/SignalMapperFactory.java @@ -33,6 +33,7 @@ import org.eclipse.ditto.things.model.signals.commands.query.ThingQueryCommandResponse; import org.eclipse.ditto.things.model.signals.events.ThingEvent; import org.eclipse.ditto.things.model.signals.events.ThingMerged; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; import org.eclipse.ditto.thingsearch.model.signals.commands.ThingSearchCommand; import org.eclipse.ditto.thingsearch.model.signals.events.SubscriptionEvent; @@ -71,6 +72,10 @@ public static SignalMapper newThingMergedEventSignalMapper() { return new ThingMergedEventSignalMapper(); } + public static SignalMapper newThingMigratedEventSignalMapper() { + return new ThingMigratedEventSignalMapper(); + } + public static SignalMapper> newThingQuerySignalMapper() { return new ThingQuerySignalMapper(); } diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigratedEventSignalMapper.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigratedEventSignalMapper.java new file mode 100644 index 0000000000..5b83e6aa99 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigratedEventSignalMapper.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 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.protocol.mapper; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.protocol.PayloadBuilder; +import org.eclipse.ditto.protocol.ProtocolFactory; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.protocol.TopicPathBuilder; +import org.eclipse.ditto.protocol.UnknownChannelException; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; + +/** + * Signal mapper for {@link ThingMigrated} events. + */ +final class ThingMigratedEventSignalMapper extends AbstractSignalMapper { + + @Override + void enhancePayloadBuilder(final ThingMigrated signal, final PayloadBuilder payloadBuilder) { + payloadBuilder.withRevision(signal.getRevision()) + .withTimestamp(signal.getTimestamp().orElse(null)) + .withValue(signal.getValue()); + } + + @Override + DittoHeaders enhanceHeaders(final ThingMigrated signal) { + return ProtocolFactory.newHeadersWithJsonMergePatchContentType(signal.getDittoHeaders()); + } + + @Override + TopicPath getTopicPath(final ThingMigrated signal, final TopicPath.Channel channel) { + + TopicPathBuilder topicPathBuilder = ProtocolFactory.newTopicPathBuilder(signal.getEntityId()) + .things(); + if (TopicPath.Channel.TWIN == channel) { + topicPathBuilder = topicPathBuilder.twin(); + } else if (TopicPath.Channel.LIVE == channel) { + topicPathBuilder = topicPathBuilder.live(); + } else { + throw UnknownChannelException.newBuilder(channel, signal.getType()) + .dittoHeaders(signal.getDittoHeaders()) + .build(); + } + return topicPathBuilder + .events() + .migrated() + .build(); + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/MappingStrategiesFactory.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/MappingStrategiesFactory.java index 954e2b1e03..7979c9c51f 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/MappingStrategiesFactory.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/MappingStrategiesFactory.java @@ -94,6 +94,10 @@ public static ThingMergedEventMappingStrategies getThingMergedEventMappingStrate return ThingMergedEventMappingStrategies.getInstance(); } + public static ThingMigratedEventMappingStrategies getThingMigratedEventMappingStrategies() { + return ThingMigratedEventMappingStrategies.getInstance(); + } + public static SubscriptionEventMappingStrategies getSubscriptionEventMappingStrategies( final ErrorRegistry errorRegistry) { return SubscriptionEventMappingStrategies.getInstance(errorRegistry); diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/ThingMigratedEventMappingStrategies.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/ThingMigratedEventMappingStrategies.java new file mode 100644 index 0000000000..1498b4f5f1 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/ThingMigratedEventMappingStrategies.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 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.protocol.mappingstrategies; + +import java.time.Instant; +import java.util.HashMap; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.json.JsonMissingFieldException; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.base.model.entity.metadata.Metadata; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.JsonifiableMapper; +import org.eclipse.ditto.protocol.Payload; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; + +/** + * Defines mapping strategies (map from signal type to JsonifiableMapper) for migrated thing events. + */ +final class ThingMigratedEventMappingStrategies extends AbstractThingMappingStrategies { + + private static final ThingMigratedEventMappingStrategies INSTANCE = new ThingMigratedEventMappingStrategies(); + + private ThingMigratedEventMappingStrategies() { + super(new HashMap<>()); + } + + static ThingMigratedEventMappingStrategies getInstance() { + return INSTANCE; + } + + @Override + public JsonifiableMapper find(final String type) { + return ThingMigratedEventMappingStrategies::thingMigrated; + } + + private static ThingMigrated thingMigrated(final Adaptable adaptable) { + return ThingMigrated.of( + thingIdFrom(adaptable), + JsonPointer.of(adaptable.getPayload().getPath().toString()), + adaptable.getPayload().getValue().orElse(null), + revisionFrom(adaptable), + timestampFrom(adaptable), + dittoHeadersFrom(adaptable), + metadataFrom(adaptable) + ); + } + + private static long revisionFrom(final Adaptable adaptable) { + return adaptable.getPayload().getRevision() + .orElseThrow(() -> JsonMissingFieldException.newBuilder() + .fieldName(Payload.JsonFields.REVISION.getPointer().toString()).build()); + } + + @Nullable + private static Instant timestampFrom(final Adaptable adaptable) { + return adaptable.getPayload().getTimestamp().orElse(null); + } + + @Nullable + private static Metadata metadataFrom(final Adaptable adaptable) { + return adaptable.getPayload().getMetadata().orElse(null); + } +} diff --git a/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapterTest.java b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapterTest.java new file mode 100644 index 0000000000..e770487930 --- /dev/null +++ b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapterTest.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 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.protocol.adapter.things; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.ditto.protocol.TopicPath.Channel.LIVE; + +import java.time.Instant; + +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.base.model.headers.DittoHeaderDefinition; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.headers.contenttype.ContentType; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.adapter.DittoProtocolAdapter; +import org.eclipse.ditto.protocol.EventsTopicPathBuilder; +import org.eclipse.ditto.protocol.LiveTwinTest; +import org.eclipse.ditto.protocol.Payload; +import org.eclipse.ditto.protocol.adapter.ProtocolAdapterTest; +import org.eclipse.ditto.protocol.TestConstants; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.protocol.TopicPathBuilder; +import org.eclipse.ditto.protocol.UnknownPathException; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; +import org.junit.Before; +import org.junit.Test; + +/** + * Unit test for {@link org.eclipse.ditto.protocol.adapter.things.ThingMigratedEventAdapter}. + */ +public final class ThingMigratedEventAdapterTest extends LiveTwinTest implements ProtocolAdapterTest { + + private ThingMigratedEventAdapter underTest; + + @Before + public void setUp() { + underTest = ThingMigratedEventAdapter.of(DittoProtocolAdapter.getHeaderTranslator()); + } + + @Test(expected = UnknownPathException.class) + public void unknownCommandFromAdaptable() { + final Instant now = Instant.now(); + final Adaptable adaptable = Adaptable.newBuilder(topicPathMigrated()) + .withPayload(Payload.newBuilder(JsonPointer.of("/_unknown")) + .withValue(TestConstants.THING.toJson()) + .withRevision(TestConstants.REVISION) + .withTimestamp(now) + .build()) + .withHeaders(TestConstants.HEADERS_V_2) + .build(); + + underTest.fromAdaptable(adaptable); + } + + @Test + public void thingMigratedFromAdaptable() { + final JsonPointer path = TestConstants.THING_POINTER; + final JsonValue value = TestConstants.THING.toJson(); + final long revision = TestConstants.REVISION; + + final Instant now = Instant.now(); + final ThingMigrated expected = + ThingMigrated.of(TestConstants.THING_ID, path, value, + revision, now, setChannelHeader(TestConstants.DITTO_HEADERS_V_2), null); + + final Adaptable adaptable = Adaptable.newBuilder(topicPathMigrated()) + .withPayload(Payload.newBuilder(path) + .withValue(value) + .withRevision(revision) + .withTimestamp(now) + .build()) + .withHeaders(TestConstants.HEADERS_V_2) + .build(); + final ThingMigrated actual = underTest.fromAdaptable(adaptable); + + assertWithExternalHeadersThat(actual).isEqualTo(expected); + } + + @Test + public void thingMigratedToAdaptable() { + final JsonPointer path = TestConstants.THING_POINTER; + final JsonValue value = TestConstants.THING.toJson(); + final long revision = TestConstants.REVISION; + + final Instant now = Instant.now(); + final Adaptable expected = Adaptable.newBuilder(topicPathMigrated()) + .withPayload(Payload.newBuilder(path) + .withValue(value) + .withRevision(revision) + .withTimestamp(now) + .build()) + .withHeaders(TestConstants.HEADERS_V_2) + .build(); + + final ThingMigrated thingMigrated = + ThingMigrated.of(TestConstants.THING_ID, path, value, + revision, now, setChannelHeader(TestConstants.DITTO_HEADERS_V_2), null); + final Adaptable actual = underTest.toAdaptable(thingMigrated, channel); + + assertWithExternalHeadersThat(actual).isEqualTo(expected); + assertThat(actual.getDittoHeaders()).containsEntry(DittoHeaderDefinition.CONTENT_TYPE.getKey(), + ContentType.APPLICATION_MERGE_PATCH_JSON.getValue()); + } + + private DittoHeaders setChannelHeader(final DittoHeaders dittoHeaders) { + if (channel == LIVE) { + return dittoHeaders.toBuilder().channel(LIVE.getName()).build(); + } else { + return dittoHeaders; + } + } + + private TopicPath topicPathMigrated() { + return topicPathBuilder().migrated().build(); + } + + private EventsTopicPathBuilder topicPathBuilder() { + final TopicPathBuilder topicPathBuilder = TopicPath.newBuilder(TestConstants.THING_ID) + .things(); + + if (channel == LIVE) { + topicPathBuilder.live(); + } else { + topicPathBuilder.twin(); + } + + return topicPathBuilder.events(); + } + +} diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java index f9d9c43102..95ff4b0649 100644 --- a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java @@ -64,13 +64,14 @@ public final class MigrateThingDefinition extends AbstractCommand patchConditions; - private final Boolean initializeMissingPropertiesFromDefaults; + private final boolean initializeMissingPropertiesFromDefaults; + private MigrateThingDefinition(final ThingId thingId, final String thingDefinitionUrl, final JsonObject migrationPayload, final Map patchConditions, - final Boolean initializeMissingPropertiesFromDefaults, + final boolean initializeMissingPropertiesFromDefaults, final DittoHeaders dittoHeaders) { super(TYPE, FeatureToggle.checkMergeFeatureEnabled(TYPE, dittoHeaders)); this.thingId = checkNotNull(thingId, "thingId"); @@ -78,7 +79,7 @@ private MigrateThingDefinition(final ThingId thingId, this.migrationPayload = checkJsonSize(checkNotNull(migrationPayload, "migrationPayload"), dittoHeaders); this.patchConditions = Collections.unmodifiableMap(patchConditions != null ? patchConditions : Collections.emptyMap()); - this.initializeMissingPropertiesFromDefaults = initializeMissingPropertiesFromDefaults != null ? initializeMissingPropertiesFromDefaults : Boolean.FALSE; + this.initializeMissingPropertiesFromDefaults = initializeMissingPropertiesFromDefaults; checkSchemaVersion(); } @@ -123,8 +124,8 @@ public static MigrateThingDefinition fromJson(final JsonObject jsonObject, final field -> field.getValue().asString() )); - final Boolean initializeMissingPropertiesFromDefaults = jsonObject.getValue(JsonFields.JSON_INITIALIZE_MISSING_PROPERTIES_FROM_DEFAULTS) - .orElse(Boolean.FALSE); + final boolean initializeMissingPropertiesFromDefaults = jsonObject.getValue(JsonFields.JSON_INITIALIZE_MISSING_PROPERTIES_FROM_DEFAULTS) + .orElse(false); return new MigrateThingDefinition( ThingId.of(thingIdStr), diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionResponse.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionResponse.java new file mode 100644 index 0000000000..47a9c789db --- /dev/null +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionResponse.java @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2025 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.things.model.signals.commands.modify; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Collections; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.common.HttpStatus; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonParsableCommandResponse; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.signals.commands.AbstractCommandResponse; +import org.eclipse.ditto.base.model.signals.commands.CommandResponseHttpStatusValidator; +import org.eclipse.ditto.base.model.signals.commands.CommandResponseJsonDeserializer; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.signals.commands.ThingCommandResponse; + +/** + * Response to a {@link MigrateThingDefinition} command. + */ +@Immutable +@JsonParsableCommandResponse(type = MigrateThingDefinitionResponse.TYPE) +public final class MigrateThingDefinitionResponse extends AbstractCommandResponse + implements ThingModifyCommandResponse { + + public static final String TYPE = ThingCommandResponse.TYPE_PREFIX + MigrateThingDefinition.NAME; + + private static final CommandResponseJsonDeserializer JSON_DESERIALIZER = + CommandResponseJsonDeserializer.newInstance(TYPE, + context -> { + final JsonObject jsonObject = context.getJsonObject(); + return newInstance( + ThingId.of(jsonObject.getValueOrThrow(JsonFields.JSON_THING_ID)), + jsonObject.getValueOrThrow(JsonFields.JSON_PATCH), + MergeStatus.fromString(jsonObject.getValueOrThrow(JsonFields.JSON_MERGE_STATUS)), + context.getDeserializedHttpStatus(), + context.getDittoHeaders() + ); + }); + + private final ThingId thingId; + private final JsonObject patch; + private final MergeStatus mergeStatus; + + private static final HttpStatus HTTP_STATUS = HttpStatus.OK; + + private MigrateThingDefinitionResponse(final ThingId thingId, + final JsonObject patch, + final MergeStatus mergeStatus, + final HttpStatus httpStatus, + final DittoHeaders dittoHeaders) { + + super(TYPE, httpStatus, dittoHeaders); + this.thingId = checkNotNull(thingId, "thingId"); + this.patch = checkNotNull(patch, "patch"); + this.mergeStatus = checkNotNull(mergeStatus, "mergeStatus"); + } + + /** + * Helper class for defining JSON field names. + */ + @Immutable + public static final class JsonFields { + public static final JsonFieldDefinition JSON_THING_ID = + JsonFactory.newStringFieldDefinition("thingId", FieldType.REGULAR, JsonSchemaVersion.V_2); + + public static final JsonFieldDefinition JSON_PATCH = + JsonFactory.newJsonObjectFieldDefinition("patch", FieldType.REGULAR, JsonSchemaVersion.V_2); + + public static final JsonFieldDefinition JSON_MERGE_STATUS = + JsonFactory.newStringFieldDefinition("mergeStatus", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private JsonFields() { + throw new AssertionError(); + } + } + + /** + * Enum for possible migration statuses. + */ + public enum MergeStatus { + APPLIED, + DRY_RUN; + + public static MergeStatus fromString(String status) { + for (MergeStatus s : values()) { + if (s.name().equalsIgnoreCase(status)) { + return s; + } + } + throw new IllegalArgumentException("Unknown MergeStatus: " + status); + } + } + + /** + * Creates a response indicating that the migration was applied successfully. + * + * @param thingId The Thing ID of the migrated entity. + * @param patch The JSON patch applied. + * @param dittoHeaders The headers for the response. + * @return An instance of {@link MigrateThingDefinitionResponse} indicating a successful migration. + */ + public static MigrateThingDefinitionResponse applied(final ThingId thingId, + final JsonObject patch, + final DittoHeaders dittoHeaders) { + return newInstance(thingId, patch, MergeStatus.APPLIED, HTTP_STATUS, dittoHeaders); + } + + /** + * Creates a response for a dry-run execution of the migration. + * + * @param thingId The Thing ID being checked. + * @param patch The JSON patch that would have been applied. + * @param dittoHeaders The headers for the response. + * @return An instance of {@link MigrateThingDefinitionResponse} indicating a dry-run migration. + */ + public static MigrateThingDefinitionResponse dryRun(final ThingId thingId, + final JsonObject patch, + final DittoHeaders dittoHeaders) { + return newInstance(thingId, patch, MergeStatus.DRY_RUN, HTTP_STATUS, dittoHeaders); + } + + /** + * Creates a new instance of {@link MigrateThingDefinitionResponse}. + * + * @param thingId The Thing ID being modified. + * @param patch The JSON patch applied to the Thing. + * @param mergeStatus The status of the migration. + * @param httpStatus The HTTP status code. + * @param dittoHeaders The headers for the response. + * @return A new instance of {@link MigrateThingDefinitionResponse}. + */ + public static MigrateThingDefinitionResponse newInstance(final ThingId thingId, + final JsonObject patch, + final MergeStatus mergeStatus, + final HttpStatus httpStatus, + final DittoHeaders dittoHeaders) { + + return new MigrateThingDefinitionResponse(thingId, patch, mergeStatus, + CommandResponseHttpStatusValidator.validateHttpStatus(httpStatus, + Collections.singleton(HTTP_STATUS), + MigrateThingDefinitionResponse.class), + dittoHeaders); + } + + /** + * Parses a {@code MigrateThingDefinitionResponse} from JSON. + * + * @param jsonObject The JSON object. + * @param dittoHeaders The headers associated with the command. + * @return A {@link MigrateThingDefinitionResponse} instance. + */ + public static MigrateThingDefinitionResponse fromJson(final JsonObject jsonObject, final DittoHeaders dittoHeaders) { + return JSON_DESERIALIZER.deserialize(jsonObject, dittoHeaders); + } + + /** + * Retrieves the JSON patch applied during migration. + * + * @return The JSON patch. + */ + public JsonObject getPatch() { + return patch; + } + + /** + * Retrieves the status of the migration (e.g., {@code APPLIED}, {@code DRY_RUN}). + * + * @return The merge status. + */ + public MergeStatus getMergeStatus() { + return mergeStatus; + } + + @Override + public JsonPointer getResourcePath() { + return JsonPointer.empty(); + } + + @Override + protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, + final JsonSchemaVersion schemaVersion, + final Predicate predicate) { + jsonObjectBuilder.set(JsonFields.JSON_THING_ID, thingId.toString(), predicate); + jsonObjectBuilder.set(JsonFields.JSON_PATCH, patch, predicate); + jsonObjectBuilder.set(JsonFields.JSON_MERGE_STATUS, mergeStatus.name(), predicate); + } + + @Override + public boolean equals(@Nullable final Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + final MigrateThingDefinitionResponse that = (MigrateThingDefinitionResponse) obj; + return thingId.equals(that.thingId) && + patch.equals(that.patch) && + mergeStatus == that.mergeStatus && + super.equals(obj); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), thingId, patch, mergeStatus); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + super.toString() + + ", thingId=" + thingId + + ", patch=" + patch + + ", mergeStatus=" + mergeStatus.name() + + "]"; + } + + @Override + public MigrateThingDefinitionResponse setDittoHeaders(final DittoHeaders dittoHeaders) { + return newInstance(thingId, patch, mergeStatus, getHttpStatus(), dittoHeaders); + } + + @Override + public JsonSchemaVersion[] getSupportedSchemaVersions() { + return new JsonSchemaVersion[]{JsonSchemaVersion.V_2}; + } + + @Override + public MigrateThingDefinitionResponse setEntity(final JsonValue entity) { + JsonObject jsonObject = entity.asObject(); + return new MigrateThingDefinitionResponse( + ThingId.of(jsonObject.getValueOrThrow(JsonFields.JSON_THING_ID)), + jsonObject.getValueOrThrow(JsonFields.JSON_PATCH), + MergeStatus.fromString(jsonObject.getValueOrThrow(JsonFields.JSON_MERGE_STATUS)), + getHttpStatus(), + getDittoHeaders() + ); + } + + @Override + public Optional getEntity(JsonSchemaVersion schemaVersion) { + return Optional.of(JsonObject.newBuilder() + .set(JsonFields.JSON_THING_ID, thingId.toString()) + .set(JsonFields.JSON_PATCH, patch) + .set(JsonFields.JSON_MERGE_STATUS, mergeStatus.name()) + .build()); + } + + @Override + public ThingId getEntityId() { + return thingId; + } +} + + diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingEventToThingConverter.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingEventToThingConverter.java index 2c6d28be3e..db40d2faa9 100644 --- a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingEventToThingConverter.java +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingEventToThingConverter.java @@ -121,6 +121,17 @@ private static Map, BiFunction, ThingBuilder.FromScratch, return ThingsModelFactory.newThing(mergedJson.asObject()); } ); + mappers.put(ThingMigrated.class, + (te, tb) -> { + final ThingMigrated thingMigrated = (ThingMigrated) te; + final JsonPointer resourcePath = thingMigrated.getResourcePath(); + final JsonValue nonNullValue = filterNullValuesInJsonValue(thingMigrated.getValue()); + final JsonObject mergedFields = JsonFactory.newObject(resourcePath, nonNullValue); + final JsonObject thingWithoutMergedFields = tb.build().toJson(FieldType.all()); + final JsonValue mergedJson = JsonFactory.mergeJsonValues(thingWithoutMergedFields, mergedFields); + return ThingsModelFactory.newThing(mergedJson.asObject()); + } + ); mappers.put(ThingDeleted.class, (te, tb) -> tb.build()); diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java new file mode 100644 index 0000000000..5bd595a05f --- /dev/null +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2025 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.things.model.signals.events; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.entity.metadata.Metadata; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonParsableEvent; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.signals.FeatureToggle; +import org.eclipse.ditto.base.model.signals.UnsupportedSchemaVersionException; +import org.eclipse.ditto.base.model.signals.commands.Command; +import org.eclipse.ditto.base.model.signals.events.EventJsonDeserializer; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; + +/** + * This event is emitted after a {@link org.eclipse.ditto.things.model.Thing} was successfully migrated. + * + * @since 3.7.0 + */ +@Immutable +@JsonParsableEvent(name = ThingMigrated.NAME, typePrefix = ThingEvent.TYPE_PREFIX) +public final class ThingMigrated extends AbstractThingEvent implements ThingModifiedEvent { + + /** + * Name of the "Thing Migrated" event. + */ + public static final String NAME = "thingMigrated"; + + /** + * Type of this event. + */ + public static final String TYPE = TYPE_PREFIX + NAME; + + private final ThingId thingId; + private final JsonPointer path; + private final JsonValue value; + + private ThingMigrated(final ThingId thingId, + final JsonPointer path, + final JsonValue value, + final long revision, + @Nullable final Instant timestamp, + final DittoHeaders dittoHeaders, + @Nullable final Metadata metadata) { + super(TYPE, thingId, revision, timestamp, FeatureToggle.checkMergeFeatureEnabled(TYPE, dittoHeaders), metadata); + this.thingId = checkNotNull(thingId, "thingId"); + this.path = checkNotNull(path, "path"); + this.value = checkNotNull(value, "value"); + checkSchemaVersion(); + } + + /** + * Creates an event of a migrated thing. + * + * @param thingId The thing ID. + * @param path The path where the changes were applied. + * @param value The value describing the changes that were migrated. + * @param revision The revision number of the thing. + * @param timestamp The event timestamp. + * @param dittoHeaders The Ditto headers. + * @param metadata The metadata associated with the event. + * @return The created {@code ThingMigrated} event. + */ + public static ThingMigrated of(final ThingId thingId, + final JsonPointer path, + final JsonValue value, + final long revision, + @Nullable final Instant timestamp, + final DittoHeaders dittoHeaders, + @Nullable final Metadata metadata) { + return new ThingMigrated(thingId, path, value, revision, timestamp, dittoHeaders, metadata); + } + + /** + * Creates a new {@code ThingMigrated} event from a JSON object. + * + * @param jsonObject The JSON object from which the event is created. + * @param dittoHeaders The headers of the command. + * @return The {@code ThingMigrated} event created from JSON. + */ + public static ThingMigrated fromJson(final JsonObject jsonObject, final DittoHeaders dittoHeaders) { + return new EventJsonDeserializer(TYPE, jsonObject).deserialize( + (revision, timestamp, metadata) -> { + final ThingId thingId = ThingId.of(jsonObject.getValueOrThrow(ThingEvent.JsonFields.THING_ID)); + final JsonPointer path = JsonPointer.of(jsonObject.getValueOrThrow(JsonFields.JSON_PATH)); + final JsonValue value = jsonObject.getValueOrThrow(JsonFields.JSON_VALUE); + + return of(thingId, path, value, revision, timestamp, dittoHeaders, metadata); + }); + } + + @Override + protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, + final JsonSchemaVersion schemaVersion, final Predicate predicate) { + jsonObjectBuilder.set(ThingEvent.JsonFields.THING_ID, thingId.toString(), predicate); + jsonObjectBuilder.set(JsonFields.JSON_PATH, path.toString(), predicate); + jsonObjectBuilder.set(JsonFields.JSON_VALUE, value, predicate); + } + + @Override + public ThingMigrated setDittoHeaders(final DittoHeaders dittoHeaders) { + return of(thingId, path, value, getRevision(), getTimestamp().orElse(null), dittoHeaders, + getMetadata().orElse(null)); + } + + @Override + public Command.Category getCommandCategory() { + return Command.Category.MODIFY; + } + + @Override + public ThingMigrated setRevision(final long revision) { + return of(thingId, path, value, revision, getTimestamp().orElse(null), getDittoHeaders(), + getMetadata().orElse(null)); + } + + @Override + public JsonPointer getResourcePath() { + return path; + } + + public JsonValue getValue() { + return value; + } + + @Override + public Optional getEntity() { + return Optional.of(value); + } + + @Override + public ThingMigrated setEntity(final JsonValue entity) { + return of(thingId, path, entity, getRevision(), getTimestamp().orElse(null), getDittoHeaders(), + getMetadata().orElse(null)); + } + + @Override + public JsonSchemaVersion[] getSupportedSchemaVersions() { + return new JsonSchemaVersion[]{JsonSchemaVersion.V_2}; + } + + private void checkSchemaVersion() { + final JsonSchemaVersion implementedSchemaVersion = getImplementedSchemaVersion(); + if (!implementsSchemaVersion(implementedSchemaVersion)) { + throw UnsupportedSchemaVersionException.newBuilder(implementedSchemaVersion).build(); + } + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + final ThingMigrated that = (ThingMigrated) o; + return that.canEqual(this) && thingId.equals(that.thingId) && + path.equals(that.path) && + value.equals(that.value); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), thingId, path, value); + } + @Override + public String toString() { + return getClass().getSimpleName() + " [" + super.toString() + + ", path=" + path + + ", value=" + value + + "]"; + } + + /** + * An enumeration of the JSON fields of a {@code ThingMigrated} event. + */ + public static final class JsonFields { + + private JsonFields() { + throw new AssertionError(); + } + + public static final JsonFieldDefinition JSON_PATH = + JsonFactory.newStringFieldDefinition("path", FieldType.REGULAR, JsonSchemaVersion.V_2); + + public static final JsonFieldDefinition JSON_VALUE = + JsonFactory.newJsonValueFieldDefinition("value", FieldType.REGULAR, JsonSchemaVersion.V_2); + } + +} diff --git a/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionResponseTest.java b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionResponseTest.java new file mode 100644 index 0000000000..6b729f1477 --- /dev/null +++ b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionResponseTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 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.things.model.signals.commands.modify; + +import static org.eclipse.ditto.json.assertions.DittoJsonAssertions.assertThat; +import static org.junit.Assert.assertEquals; + + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.things.model.ThingId; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit test for {@link MigrateThingDefinitionResponse}. + */ +public final class MigrateThingDefinitionResponseTest { + + @Test + public void testHashCodeAndEquals() { + EqualsVerifier.forClass(MigrateThingDefinitionResponse.class) + .withRedefinedSuperclass() + .verify(); + } + + @Test + public void testSerialization() { + final DittoHeaders dittoHeaders = DittoHeaders.newBuilder().randomCorrelationId().build(); + final ThingId thingId = ThingId.of("org.eclipse.ditto:some-thing-1"); + final JsonObject patch = JsonFactory.newObjectBuilder() + .set("attributes", JsonFactory.newObjectBuilder().set("manufacturer", "New Corp").build()) + .set("features", JsonFactory.newObjectBuilder() + .set("sensor", JsonFactory.newObjectBuilder() + .set("properties", JsonFactory.newObjectBuilder() + .set("status", JsonFactory.newObjectBuilder() + .set("temperature", JsonFactory.newObjectBuilder() + .set("value", 25.0) + .build()) + .build()) + .build()) + .build()) + .build()) + .build(); + + final MigrateThingDefinitionResponse originalResponse = + MigrateThingDefinitionResponse.applied(thingId, patch, dittoHeaders); + + final JsonObject serializedJson = originalResponse.toJson(); + + assertEquals("APPLIED", serializedJson.getValueOrThrow(MigrateThingDefinitionResponse.JsonFields.JSON_MERGE_STATUS)); + + final MigrateThingDefinitionResponse deserializedResponse = + MigrateThingDefinitionResponse.fromJson(serializedJson, dittoHeaders); + + assertThat(deserializedResponse).isEqualTo(originalResponse); + assertEquals(MigrateThingDefinitionResponse.MergeStatus.APPLIED, deserializedResponse.getMergeStatus()); + } + + @Test + public void testDryRunSerialization() { + final DittoHeaders dittoHeaders = DittoHeaders.newBuilder().randomCorrelationId().build(); + final ThingId thingId = ThingId.of("org.eclipse.ditto:some-thing-2"); + final JsonObject patch = JsonFactory.newObjectBuilder() + .set("attributes", JsonFactory.newObjectBuilder().set("location", "Room 101").build()) + .build(); + + final MigrateThingDefinitionResponse dryRunResponse = + MigrateThingDefinitionResponse.dryRun(thingId, patch, dittoHeaders); + + final JsonObject serializedJson = dryRunResponse.toJson(); + + assertEquals("DRY_RUN", serializedJson.getValueOrThrow(MigrateThingDefinitionResponse.JsonFields.JSON_MERGE_STATUS)); + + final MigrateThingDefinitionResponse deserializedResponse = + MigrateThingDefinitionResponse.fromJson(serializedJson, dittoHeaders); + + assertThat(deserializedResponse).isEqualTo(dryRunResponse); + assertEquals(MigrateThingDefinitionResponse.MergeStatus.DRY_RUN, deserializedResponse.getMergeStatus()); + } + + @Test + public void testToString() { + final DittoHeaders dittoHeaders = DittoHeaders.newBuilder().randomCorrelationId().build(); + final ThingId thingId = ThingId.of("org.eclipse.ditto:some-thing-3"); + final JsonObject patch = JsonFactory.newObjectBuilder().build(); + + final MigrateThingDefinitionResponse response = + MigrateThingDefinitionResponse.applied(thingId, patch, dittoHeaders); + + final String responseString = response.toString(); + + assertThat(responseString).contains("MigrateThingDefinitionResponse"); + assertThat(responseString).contains("mergeStatus=APPLIED"); + } +} diff --git a/things/model/src/test/java/org/eclipse/ditto/things/model/signals/events/ThingMigratedTest.java b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/events/ThingMigratedTest.java new file mode 100644 index 0000000000..a175a40a45 --- /dev/null +++ b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/events/ThingMigratedTest.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 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.things.model.signals.events; + +import static org.eclipse.ditto.json.assertions.DittoJsonAssertions.assertThat; + +import java.lang.ref.SoftReference; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.signals.events.Event; +import org.eclipse.ditto.base.model.signals.events.EventsourcedEvent; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit test for {@link ThingMigrated}. + */ +public final class ThingMigratedTest { + + private static final JsonObject KNOWN_JSON = JsonFactory.newObjectBuilder() + .set(Event.JsonFields.TIMESTAMP, TestConstants.TIMESTAMP.toString()) + .set(Event.JsonFields.TYPE, ThingMigrated.TYPE) + .set(Event.JsonFields.METADATA, TestConstants.METADATA.toJson()) + .set(EventsourcedEvent.JsonFields.REVISION, TestConstants.Thing.REVISION_NUMBER) + .set(ThingEvent.JsonFields.THING_ID, TestConstants.Thing.THING_ID.toString()) + .set(ThingMigrated.JsonFields.JSON_PATH, TestConstants.Thing.ABSOLUTE_LOCATION_ATTRIBUTE_POINTER.toString()) + .set(ThingMigrated.JsonFields.JSON_VALUE, TestConstants.Thing.LOCATION_ATTRIBUTE_VALUE) + .build(); + + /** + * Ensures that equals and hashCode behave correctly for different instances. + */ + @Test + public void testHashCodeAndEquals() { + final SoftReference red = new SoftReference<>(JsonFactory.newObject("{\"foo\": 1}")); + final SoftReference black = new SoftReference<>(JsonFactory.newObject("{\"foo\": 2}")); + + EqualsVerifier.forClass(ThingMigrated.class) + .withRedefinedSuperclass() + .withPrefabValues(SoftReference.class, red, black) + .verify(); + } + + /** + * Tests if toJson correctly serializes a {@code ThingMigrated} event. + */ + @Test + public void toJsonReturnsExpected() { + final ThingMigrated underTest = + ThingMigrated.of(TestConstants.Thing.THING_ID, + TestConstants.Thing.ABSOLUTE_LOCATION_ATTRIBUTE_POINTER, + TestConstants.Thing.LOCATION_ATTRIBUTE_VALUE, + TestConstants.Thing.REVISION_NUMBER, + TestConstants.TIMESTAMP, + TestConstants.EMPTY_DITTO_HEADERS, + TestConstants.METADATA); + final JsonObject actualJson = underTest.toJson(FieldType.regularOrSpecial()); + + assertThat(actualJson).isEqualTo(KNOWN_JSON); + } + + /** + * Verifies that a {@code ThingMigrated} instance can be created from valid JSON. + */ + @Test + public void createInstanceFromValidJson() { + final ThingMigrated underTest = + ThingMigrated.fromJson(KNOWN_JSON, TestConstants.EMPTY_DITTO_HEADERS); + + assertThat(underTest).isNotNull(); + assertThat((Object) underTest.getEntityId()).isEqualTo(TestConstants.Thing.THING_ID); + assertThat(underTest.getResourcePath()).isEqualTo(TestConstants.Thing.ABSOLUTE_LOCATION_ATTRIBUTE_POINTER); + assertThat(underTest.getValue()).isEqualTo(TestConstants.Thing.LOCATION_ATTRIBUTE_VALUE); + assertThat(underTest.getMetadata()).contains(TestConstants.METADATA); + assertThat(underTest.getRevision()).isEqualTo(TestConstants.Thing.REVISION_NUMBER); + assertThat(underTest.getTimestamp()).contains(TestConstants.TIMESTAMP); + } +} diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java index bca4686b0e..8149848baf 100644 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java @@ -31,9 +31,12 @@ import org.eclipse.ditto.internal.utils.persistentactors.results.Result; import org.eclipse.ditto.internal.utils.persistentactors.results.ResultFactory; import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonKey; import org.eclipse.ditto.json.JsonObject; import org.eclipse.ditto.json.JsonObjectBuilder; import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; import org.eclipse.ditto.placeholders.PlaceholderFactory; import org.eclipse.ditto.placeholders.TimePlaceholder; import org.eclipse.ditto.policies.model.ResourceKey; @@ -47,10 +50,10 @@ import org.eclipse.ditto.things.model.signals.commands.ThingCommandSizeValidator; import org.eclipse.ditto.things.model.signals.commands.ThingResourceMapper; import org.eclipse.ditto.things.model.signals.commands.exceptions.SkeletonGenerationFailedException; -import org.eclipse.ditto.things.model.signals.commands.modify.MergeThingResponse; import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinitionResponse; import org.eclipse.ditto.things.model.signals.events.ThingEvent; -import org.eclipse.ditto.things.model.signals.events.ThingMerged; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; /** @@ -113,6 +116,7 @@ private Result> handleMigrateDefinition( @Nullable final Metadata metadata) { final DittoHeaders dittoHeaders = command.getDittoHeaders(); + final boolean isDryRun = dittoHeaders.isDryRun(); final JsonPointer path = JsonPointer.empty(); // 1. Evaluate Patch Conditions and modify the migrationPayload @@ -124,8 +128,8 @@ private Result> handleMigrateDefinition( // 2. Generate Skeleton using definition and apply migration final CompletionStage updatedThingStage = generateSkeleton(command, dittoHeaders) - .thenApply(skeleton -> mergeSkeletonWithThing( - existingThing, skeleton, command.getThingDefinitionUrl(), + .thenApply(skeleton -> resolveSkeletonConflicts( + existingThing, skeleton, command.isInitializeMissingPropertiesFromDefaults())) .thenApply(mergedThing -> applyMigrationPayload(context, mergedThing, adjustedMigrationPayload, dittoHeaders, nextRevision, eventTs)); @@ -135,12 +139,26 @@ private Result> handleMigrateDefinition( .thenCompose(mergedThing -> buildValidatedStage(command, existingThing, mergedThing) .thenApply(migrateThingDefinition -> new Pair<>(mergedThing, migrateThingDefinition))); - final CompletionStage> eventStage = validatedStage.thenApply(pair -> ThingMerged.of( + // If Dry Run, return a simulated response without applying changes + if (isDryRun) { + return ResultFactory.newQueryResult( + command, + validatedStage.thenApply(pair -> + MigrateThingDefinitionResponse.dryRun( + existingThing.getEntityId().get(), + pair.first().toJson(), + dittoHeaders)) + ); + } + + // 4. Apply migration and generate event + final CompletionStage> eventStage = validatedStage.thenApply(pair -> ThingMigrated.of( pair.second().getEntityId(), path, pair.first().toJson(), nextRevision, eventTs, dittoHeaders, metadata)); final CompletionStage responseStage = validatedStage.thenApply(pair -> - appendETagHeaderIfProvided(command, MergeThingResponse.of(command.getEntityId(), path, dittoHeaders), + appendETagHeaderIfProvided(command, MigrateThingDefinitionResponse.applied(existingThing.getEntityId().get(), + pair.first().toJson(), dittoHeaders), pair.first())); return ResultFactory.newMutationResult(command, eventStage, responseStage); @@ -201,53 +219,110 @@ private CompletionStage generateSkeleton( ThingsModelFactory.newDefinition(command.getThingDefinitionUrl()), dittoHeaders ) - .thenApply(optionalSkeleton -> optionalSkeleton.orElseThrow(() -> - SkeletonGenerationFailedException.newBuilder(command.getEntityId()) - .dittoHeaders(command.getDittoHeaders()) - .build() - )); + .thenApply(optionalSkeleton -> { + Thing skeleton = optionalSkeleton.orElseThrow(() -> + SkeletonGenerationFailedException.newBuilder(command.getEntityId()) + .dittoHeaders(command.getDittoHeaders()) + .build() + ); + + return skeleton.toBuilder() + .setDefinition(ThingsModelFactory.newDefinition(command.getThingDefinitionUrl())) + .build(); + }); } - private Thing extractDefinitions(final Thing thing, final String thingDefinitionUrl) { + + private Thing extractDefinitions(final Thing thing) { var thingBuilder = ThingsModelFactory.newThingBuilder(); - thingBuilder.setDefinition(ThingsModelFactory.newDefinition(thingDefinitionUrl)); - thing.getFeatures().orElseGet(ThingsModelFactory::emptyFeatures).forEach(feature -> { - thingBuilder.setFeature(feature.getId(), feature.getDefinition().get(), null); - }); + thing.getFeatures().orElseGet(ThingsModelFactory::emptyFeatures).forEach(feature -> + thingBuilder.setFeature(feature.getId(), feature.getDefinition().get(), null)); return thingBuilder.build(); } - private Thing extractDefaultValues(final Thing thing) { - var thingBuilder = ThingsModelFactory.newThingBuilder(); - thingBuilder.setAttributes(thing.getAttributes().orElse(ThingsModelFactory.emptyAttributes())); - thing.getFeatures().orElseGet(ThingsModelFactory::emptyFeatures).forEach(feature -> { - thingBuilder.setFeature(feature.getId(), feature.getDefinition().orElse(null), null); - }); - return thingBuilder.build(); + /** + * Resolves conflicts between a skeleton Thing and an existing Thing while optionally initializing properties. + * If initialization is disabled, only definitions from the skeleton are extracted. Otherwise, conflicting + * fields are removed, and a new Thing is created with the refined values. + * + * @param existingThing The existing Thing to compare against. + * @param skeletonThing The skeleton Thing containing default values. + * @param isInitializeProperties A flag indicating whether properties should be initialized. + * @return A new Thing with conflicts resolved and properties optionally initialized. + */ + private Thing resolveSkeletonConflicts(final Thing existingThing, final Thing skeletonThing, + final boolean isInitializeProperties) { + + if (!isInitializeProperties) { + return extractDefinitions(skeletonThing); + } + + final var refinedDefaults = removeConflicts(skeletonThing.toJson(), existingThing.toJson().asObject()); + + return ThingsModelFactory.newThing(refinedDefaults); } - private Thing mergeSkeletonWithThing(final Thing existingThing, final Thing skeletonThing, - final String thingDefinitionUrl, final boolean isInitializeProperties) { + /** + * Removes conflicting fields from the default values by recursively comparing them with existing values. + * Fields containing "definition" are always retained. If a field exists in both JSON objects and is a nested + * object, the function will recursively filter out conflicting values. If a field does not exist in the + * existing values, it is retained from the default values. + * + * @param defaultValues The JsonObject containing the default values. + * @param existingValues The JsonObject containing the existing values to compare against. + * @return A new JsonObject with conflicts removed, preserving necessary fields. + */ + public static JsonObject removeConflicts(final JsonObject defaultValues, final JsonObject existingValues) { + final JsonObjectBuilder builder = JsonFactory.newObjectBuilder(); - // Extract definitions and convert to JSON - final var fullThingDefinitions = extractDefinitions(skeletonThing, thingDefinitionUrl).toJson(); + if (defaultValues.isNull() && existingValues.isNull()) { + return JsonFactory.nullObject(); + } - // Merge the extracted definitions with the existing thing JSON - final var mergedThingJson = JsonFactory.mergeJsonValues(fullThingDefinitions, existingThing.toJson()).asObject(); + for (JsonField field : defaultValues) { + final JsonKey key = field.getKey(); + final JsonValue defaultValue = field.getValue(); + final Optional maybeExistingValue = existingValues.getValue(key); - // If not initializing properties, return the merged result - if (!isInitializeProperties) { - return ThingsModelFactory.newThing(mergedThingJson); + if (key.toString().contains("definition")) { + builder.set(key, defaultValue); + continue; + } + + if (maybeExistingValue.isPresent()) { + JsonValue resolvedValue = resolveConflictingValues(defaultValue, maybeExistingValue.get()); + if (resolvedValue != null) { + builder.set(key, resolvedValue); + } + } + else { + builder.set(field); + } } - // Extract default values and merge them in - return ThingsModelFactory.newThing(JsonFactory.mergeJsonValues( - mergedThingJson, extractDefaultValues(skeletonThing).toJson()).asObject()); + return builder.build(); } + /** + * Resolves conflicting JsonValue objects by recursively comparing them. + * If both values are JsonObjects, it calls {@link #removeConflicts(JsonObject, JsonObject)} + * to recursively filter out conflicting values. Otherwise, it returns null, + * indicating that the value should not be retained. + * + * @param defaultValue The JsonValue from the default values object. + * @param existingValue The JsonValue from the existing values object. + * @return A filtered JsonObject if both values are objects; otherwise, null. + */ + private static JsonValue resolveConflictingValues(final JsonValue defaultValue, final JsonValue existingValue) { + return (defaultValue.isObject() && existingValue.isObject()) + ? removeConflicts(defaultValue.asObject(), existingValue.asObject()) + : null; + } + + private Thing applyMigrationPayload(final Context context, final Thing thing, final JsonObject migrationPayload, final DittoHeaders dittoHeaders, @@ -255,7 +330,6 @@ private Thing applyMigrationPayload(final Context context, final Thing final Instant eventTs) { final JsonObject thingJson = thing.toJson(FieldType.all()); final JsonObject mergedJson = JsonFactory.newObject(migrationPayload, thingJson); - context.getLog().debug("Thing updated from migrated JSON: {}", mergedJson); ThingCommandSizeValidator.getInstance().ensureValidSize( mergedJson::getUpperBoundForStringSize, diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingEventStrategies.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingEventStrategies.java index 42e6ca2279..94fb717232 100644 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingEventStrategies.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingEventStrategies.java @@ -51,6 +51,7 @@ import org.eclipse.ditto.things.model.signals.events.ThingDeleted; import org.eclipse.ditto.things.model.signals.events.ThingEvent; import org.eclipse.ditto.things.model.signals.events.ThingMerged; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; import org.eclipse.ditto.things.model.signals.events.ThingModified; /** @@ -86,6 +87,7 @@ private void addThingStrategies() { addStrategy(ThingModified.class, new ThingModifiedStrategy()); addStrategy(ThingDeleted.class, new ThingDeletedStrategy()); addStrategy(ThingMerged.class, new ThingMergedStrategy()); + addStrategy(ThingMigrated.class, new ThingMigratedStrategy()); } private void addAttributesStrategies() { diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingMigratedStrategy.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingMigratedStrategy.java new file mode 100644 index 0000000000..20e2063d6e --- /dev/null +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingMigratedStrategy.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 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.things.service.persistence.actors.strategies.events; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.things.model.Thing; +import org.eclipse.ditto.things.model.ThingLifecycle; +import org.eclipse.ditto.things.model.ThingsModelFactory; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +/** + * This strategy handles the {@link ThingMigrated} event. + */ +@Immutable +final class ThingMigratedStrategy extends AbstractThingEventStrategy { + + ThingMigratedStrategy() { + super(); + } + + @Nullable + @Override + public Thing handle(final ThingMigrated event, @Nullable final Thing thing, final long revision) { + if (null != thing) { + final JsonObject jsonObject = thing.toJson(FieldType.all()); + final JsonObject mergePatch = JsonFactory.newObject(event.getResourcePath(), event.getValue()); + final JsonObject mergedJson = JsonFactory.mergeJsonValues(mergePatch, jsonObject).asObject(); + return ThingsModelFactory.newThingBuilder(mergedJson) + .setRevision(revision) + .setModified(event.getTimestamp().orElse(null)) + .setLifecycle(ThingLifecycle.ACTIVE) + .setMetadata(mergeMetadata(thing, event)) + .build(); + } else { + return null; + } + } + +} diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java index 80f9c58f9f..4f50811b94 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java @@ -14,6 +14,7 @@ import javax.annotation.Nullable; +import org.eclipse.ditto.base.model.common.HttpStatus; import org.eclipse.ditto.base.model.entity.Revision; import org.eclipse.ditto.base.model.headers.DittoHeaderDefinition; import org.eclipse.ditto.base.model.headers.DittoHeaders; @@ -58,6 +59,7 @@ import org.eclipse.ditto.things.model.signals.commands.query.RetrievePolicyIdResponse; import org.eclipse.ditto.things.model.signals.commands.query.RetrieveThingDefinitionResponse; import org.eclipse.ditto.things.model.signals.commands.query.RetrieveThingResponse; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinitionResponse; /** * Provides methods to get command responses that include the correct eTag header value. @@ -361,6 +363,19 @@ public static RetrieveThingDefinitionResponse retrieveDefinitionResponse(final T return RetrieveThingDefinitionResponse.of(thingId, expectedThingDefinition, dittoHeadersWithETag); } + public static MigrateThingDefinitionResponse migrateThingDefinitionResponse(final ThingId thingId, + final JsonObject patch, final Thing mergeThing, final DittoHeaders dittoHeaders) { + + final DittoHeaders dittoHeadersWithETag = appendEntityIdAndETagToDittoHeaders(thingId, mergeThing, dittoHeaders); + + return MigrateThingDefinitionResponse.newInstance( + thingId, + patch, + MigrateThingDefinitionResponse.MergeStatus.APPLIED, + HttpStatus.OK, + dittoHeadersWithETag + ); + } protected static DittoHeaders appendEntityIdAndETagToDittoHeaders(final ThingId thingId, final Object object, diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/AbstractCommandStrategyTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/AbstractCommandStrategyTest.java index 396fbbd541..5961d6aad5 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/AbstractCommandStrategyTest.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/AbstractCommandStrategyTest.java @@ -72,9 +72,9 @@ public abstract class AbstractCommandStrategyTest { @BeforeClass public static void initTestConstants() { logger = Mockito.mock(DittoDiagnosticLoggingAdapter.class); - Mockito.when(logger.withCorrelationId(Mockito.any(DittoHeaders.class))).thenReturn(logger); - Mockito.when(logger.withCorrelationId(Mockito.any(WithDittoHeaders.class))).thenReturn(logger); - Mockito.when(logger.withCorrelationId(Mockito.any(CharSequence.class))).thenReturn(logger); + Mockito.lenient().when(logger.withCorrelationId(Mockito.any(DittoHeaders.class))).thenReturn(logger); + Mockito.lenient().when(logger.withCorrelationId(Mockito.any(WithDittoHeaders.class))).thenReturn(logger); + Mockito.lenient().when(logger.withCorrelationId(Mockito.any(CharSequence.class))).thenReturn(logger); } protected static CommandStrategy.Context getDefaultContext() { @@ -202,7 +202,7 @@ private static > T assertModificationResult(fina return event.getValue(); } - private static > T assertStagedModificationResult(final Result> result, + protected static > T assertStagedModificationResult(final Result> result, final Class eventClazz, final WithDittoHeaders expectedResponse, final boolean becomeDeleted) { diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java new file mode 100644 index 0000000000..80ed103fba --- /dev/null +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 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.things.service.persistence.actors.strategies.commands; + +import static org.eclipse.ditto.things.model.TestConstants.Thing.THING_V2; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import com.typesafe.config.ConfigFactory; + +import org.apache.pekko.actor.ActorSystem; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.internal.utils.persistentactors.commands.CommandStrategy; +import org.eclipse.ditto.internal.utils.persistentactors.results.Result; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.things.model.Thing; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.ThingRevision; +import org.eclipse.ditto.things.model.ThingsModelFactory; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinitionResponse; +import org.eclipse.ditto.things.model.signals.events.ThingEvent; +import org.eclipse.ditto.things.model.signals.events.ThingMigrated; +import org.eclipse.ditto.things.service.persistence.actors.ETagTestUtils; +import org.eclipse.ditto.wot.api.generator.WotThingSkeletonGenerator; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +/** + * Unit test for {@link MigrateThingDefinitionStrategy} with injected mock of WotThingSkeletonGenerator. + */ +@RunWith(MockitoJUnitRunner.class) +public final class MigrateThingDefinitionStrategyTest extends AbstractCommandStrategyTest { + + private MigrateThingDefinitionStrategy underTest; + private WotThingSkeletonGenerator mockWotThingSkeletonGenerator; + + @Before + public void setUp() throws Exception { + final ActorSystem actorSystem = ActorSystem.create("test", ConfigFactory.load("test")); + + mockWotThingSkeletonGenerator = mock(WotThingSkeletonGenerator.class); + + underTest = new MigrateThingDefinitionStrategy(actorSystem); + + injectMock(underTest, "wotThingSkeletonGenerator", mockWotThingSkeletonGenerator); + + when(mockWotThingSkeletonGenerator.provideThingSkeletonForCreation(any(ThingId.class), any(), any(DittoHeaders.class))) + .thenReturn(CompletableFuture.completedFuture(Optional.of(createMockThingSkeleton()))); + } + + /** + * Injects a mock into a private field of the given object. + */ + private void injectMock(Object targetObject, String fieldName, Object mock) throws Exception { + Field field = null; + Class clazz = targetObject.getClass(); + + while (clazz != Object.class) { + try { + field = clazz.getDeclaredField(fieldName); + break; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + + if (field == null) { + throw new NoSuchFieldException("Field " + fieldName + " not found in class hierarchy."); + } + + field.setAccessible(true); + field.set(targetObject, mock); + } + + + @Test + public void migrateExistingThing() { + final CommandStrategy.Context context = getDefaultContext(); + final ThingId thingId = context.getState(); + final Thing existingThing = THING_V2.toBuilder().setRevision(NEXT_REVISION - 1).build(); + + final JsonObject migrationPayload = JsonFactory.newObjectBuilder() + .set("attributes", JsonFactory.newObjectBuilder().set("manufacturer", "New Corp").build()) + .build(); + + final String mockThingDefinitionUrl = "http://mock-url-for-test.com/model.json"; + + final MigrateThingDefinition command = MigrateThingDefinition.of( + thingId, + mockThingDefinitionUrl, + migrationPayload, + null, + true, + DittoHeaders.empty() + ); + + final MigrateThingDefinitionResponse expectedResponse = ETagTestUtils.migrateThingDefinitionResponse(thingId, + JsonFactory.newObjectBuilder() + .set("definition", mockThingDefinitionUrl) + .set("attributes", JsonFactory.newObjectBuilder() + .set("manufacturer", "New Corp") + .build()) + .build(), + createMockThingSkeleton(), + command.getDittoHeaders()); + + final Result> result = underTest.apply(context, existingThing, NEXT_REVISION, command); + + result.mapStages(completionStage -> { + completionStage.toCompletableFuture().join(); + return completionStage; + }); + + assertStagedModificationResult(result, ThingMigrated.class, expectedResponse, false); + } + + /** + * Creates a mock Thing skeleton to avoid real network calls. + */ + private Thing createMockThingSkeleton() { + return ThingsModelFactory.newThingBuilder() + .setAttributes(JsonFactory.newObjectBuilder() + .set("manufacturer", "MockCorp") + .build()) + .setRevision(ThingRevision.newInstance(NEXT_REVISION)) + .build(); + } + +} From e917071072907accaeed36fd8c59bc42c176667f Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Fri, 7 Feb 2025 13:03:18 +0100 Subject: [PATCH 06/15] fix test --- .../endpoints/routes/things/ThingsRoute.java | 8 +-- .../ReflectionBasedSignalInstantiator.java | 6 +- .../modify/MigrateThingDefinition.java | 4 +- .../persistence/actors/ETagTestUtils.java | 4 +- .../MigrateThingDefinitionStrategyTest.java | 68 ++++--------------- 5 files changed, 25 insertions(+), 65 deletions(-) diff --git a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java index 52d369aef4..ae528d25ad 100755 --- a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java +++ b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java @@ -591,7 +591,7 @@ private Route thingsEntryMigrateDefinition(final RequestContext ctx, payloadSource -> handlePerRequest(ctx, dittoHeaders, payloadSource, payload -> { final JsonObject inputJson = wrapJsonRuntimeException(() -> JsonFactory.newObject(payload)); - final JsonObject updatedJson = addThingIdAndDryRun(inputJson, thingId, isDryRun(dryRun)); + final JsonObject updatedJson = addThingIdAndDryRun(inputJson, thingId); return MigrateThingDefinition.fromJson(updatedJson, dittoHeaders.toBuilder() .putHeader(DittoHeaderDefinition.DRY_RUN.getKey(),dryRun.orElse(Boolean.FALSE.toString())) .build()); @@ -600,12 +600,8 @@ private Route thingsEntryMigrateDefinition(final RequestContext ctx, )) ); } - private static boolean isDryRun(final Optional dryRun) { - return dryRun.map(Boolean::valueOf).orElse(Boolean.FALSE); - } - private static JsonObject addThingIdAndDryRun(final JsonObject inputJson, final ThingId thingId, - final boolean dryRun) { + private static JsonObject addThingIdAndDryRun(final JsonObject inputJson, final ThingId thingId) { checkNotNull(inputJson, "inputJson"); return JsonFactory.newObjectBuilder(inputJson) .set(ThingCommand.JsonFields.JSON_THING_ID, thingId.toString()) diff --git a/internal/models/signal/src/test/java/org/eclipse/ditto/internal/models/signal/common/ReflectionBasedSignalInstantiator.java b/internal/models/signal/src/test/java/org/eclipse/ditto/internal/models/signal/common/ReflectionBasedSignalInstantiator.java index 961d7bdc1c..1271c62ee1 100644 --- a/internal/models/signal/src/test/java/org/eclipse/ditto/internal/models/signal/common/ReflectionBasedSignalInstantiator.java +++ b/internal/models/signal/src/test/java/org/eclipse/ditto/internal/models/signal/common/ReflectionBasedSignalInstantiator.java @@ -49,6 +49,7 @@ import org.eclipse.ditto.things.model.ThingDefinition; import org.eclipse.ditto.things.model.ThingId; import org.eclipse.ditto.things.model.ThingsModelFactory; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinitionResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -99,7 +100,10 @@ final class ReflectionBasedSignalInstantiator { Map.entry(String.class, stringValue), Map.entry(Thing.class, Thing.newBuilder().setId(thingId).build()), Map.entry(ThingDefinition.class, ThingsModelFactory.newDefinition(definitionIdentifier)), - Map.entry(ThingId.class, thingId) + Map.entry(ThingId.class, thingId), + Map.entry(MigrateThingDefinitionResponse.MergeStatus.class, MigrateThingDefinitionResponse.MergeStatus.APPLIED), + Map.entry(boolean.class, false) + ); } diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java index 95ff4b0649..c47548b9b5 100644 --- a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java @@ -98,7 +98,7 @@ public static MigrateThingDefinition of(final ThingId thingId, final String thingDefinitionUrl, final JsonObject migrationPayload, final Map patchConditions, - final Boolean initializeMissingPropertiesFromDefaults, + final boolean initializeMissingPropertiesFromDefaults, final DittoHeaders dittoHeaders) { return new MigrateThingDefinition(thingId, thingDefinitionUrl, migrationPayload, patchConditions, initializeMissingPropertiesFromDefaults, dittoHeaders); @@ -153,7 +153,7 @@ public Map getPatchConditions() { return patchConditions; } - public Boolean isInitializeMissingPropertiesFromDefaults() { + public boolean isInitializeMissingPropertiesFromDefaults() { return initializeMissingPropertiesFromDefaults; } diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java index 4f50811b94..0aae662a0b 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java @@ -364,13 +364,13 @@ public static RetrieveThingDefinitionResponse retrieveDefinitionResponse(final T } public static MigrateThingDefinitionResponse migrateThingDefinitionResponse(final ThingId thingId, - final JsonObject patch, final Thing mergeThing, final DittoHeaders dittoHeaders) { + final Thing mergeThing, final DittoHeaders dittoHeaders) { final DittoHeaders dittoHeadersWithETag = appendEntityIdAndETagToDittoHeaders(thingId, mergeThing, dittoHeaders); return MigrateThingDefinitionResponse.newInstance( thingId, - patch, + mergeThing.toJson(), MigrateThingDefinitionResponse.MergeStatus.APPLIED, HttpStatus.OK, dittoHeadersWithETag diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java index 80ed103fba..899f217571 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java @@ -13,12 +13,7 @@ package org.eclipse.ditto.things.service.persistence.actors.strategies.commands; import static org.eclipse.ditto.things.model.TestConstants.Thing.THING_V2; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; -import java.lang.reflect.Field; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; import com.typesafe.config.ConfigFactory; @@ -37,7 +32,6 @@ import org.eclipse.ditto.things.model.signals.events.ThingEvent; import org.eclipse.ditto.things.model.signals.events.ThingMigrated; import org.eclipse.ditto.things.service.persistence.actors.ETagTestUtils; -import org.eclipse.ditto.wot.api.generator.WotThingSkeletonGenerator; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -50,47 +44,13 @@ public final class MigrateThingDefinitionStrategyTest extends AbstractCommandStrategyTest { private MigrateThingDefinitionStrategy underTest; - private WotThingSkeletonGenerator mockWotThingSkeletonGenerator; @Before public void setUp() throws Exception { final ActorSystem actorSystem = ActorSystem.create("test", ConfigFactory.load("test")); - - mockWotThingSkeletonGenerator = mock(WotThingSkeletonGenerator.class); - underTest = new MigrateThingDefinitionStrategy(actorSystem); - - injectMock(underTest, "wotThingSkeletonGenerator", mockWotThingSkeletonGenerator); - - when(mockWotThingSkeletonGenerator.provideThingSkeletonForCreation(any(ThingId.class), any(), any(DittoHeaders.class))) - .thenReturn(CompletableFuture.completedFuture(Optional.of(createMockThingSkeleton()))); - } - - /** - * Injects a mock into a private field of the given object. - */ - private void injectMock(Object targetObject, String fieldName, Object mock) throws Exception { - Field field = null; - Class clazz = targetObject.getClass(); - - while (clazz != Object.class) { - try { - field = clazz.getDeclaredField(fieldName); - break; - } catch (NoSuchFieldException e) { - clazz = clazz.getSuperclass(); - } - } - - if (field == null) { - throw new NoSuchFieldException("Field " + fieldName + " not found in class hierarchy."); - } - - field.setAccessible(true); - field.set(targetObject, mock); } - @Test public void migrateExistingThing() { final CommandStrategy.Context context = getDefaultContext(); @@ -101,11 +61,11 @@ public void migrateExistingThing() { .set("attributes", JsonFactory.newObjectBuilder().set("manufacturer", "New Corp").build()) .build(); - final String mockThingDefinitionUrl = "http://mock-url-for-test.com/model.json"; + final String thingDefinitionUrl = "https://eclipse-ditto.github.io/ditto-examples/wot/models/dimmable-colored-lamp-1.0.0.tm.jsonld"; final MigrateThingDefinition command = MigrateThingDefinition.of( thingId, - mockThingDefinitionUrl, + thingDefinitionUrl, migrationPayload, null, true, @@ -113,13 +73,7 @@ public void migrateExistingThing() { ); final MigrateThingDefinitionResponse expectedResponse = ETagTestUtils.migrateThingDefinitionResponse(thingId, - JsonFactory.newObjectBuilder() - .set("definition", mockThingDefinitionUrl) - .set("attributes", JsonFactory.newObjectBuilder() - .set("manufacturer", "New Corp") - .build()) - .build(), - createMockThingSkeleton(), + meregedThing(thingDefinitionUrl), command.getDittoHeaders()); final Result> result = underTest.apply(context, existingThing, NEXT_REVISION, command); @@ -132,13 +86,19 @@ public void migrateExistingThing() { assertStagedModificationResult(result, ThingMigrated.class, expectedResponse, false); } - /** - * Creates a mock Thing skeleton to avoid real network calls. - */ - private Thing createMockThingSkeleton() { + + private Thing meregedThing(String thingDefinitionUrl) { return ThingsModelFactory.newThingBuilder() + .setDefinition(ThingsModelFactory.newDefinition(thingDefinitionUrl)) .setAttributes(JsonFactory.newObjectBuilder() - .set("manufacturer", "MockCorp") + .set("manufacturer", "New Corp") + .set("on", false) + .set("color", JsonFactory.newObjectBuilder() + .set("r", 0) + .set("g", 0) + .set("b", 0) + .build()) + .set("dimmer-level", 0.0) .build()) .setRevision(ThingRevision.newInstance(NEXT_REVISION)) .build(); From 220eb8177c8e058500008f6b290201b6ffd3548f Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Fri, 7 Feb 2025 13:28:38 +0100 Subject: [PATCH 07/15] fix test --- .../persistence/actors/ETagTestUtils.java | 4 +- .../MigrateThingDefinitionStrategyTest.java | 68 +++++++++++++++---- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java index 0aae662a0b..4cf424a6db 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java @@ -364,13 +364,13 @@ public static RetrieveThingDefinitionResponse retrieveDefinitionResponse(final T } public static MigrateThingDefinitionResponse migrateThingDefinitionResponse(final ThingId thingId, - final Thing mergeThing, final DittoHeaders dittoHeaders) { + final JsonObject patch, final Thing mergeThing, final DittoHeaders dittoHeaders) { final DittoHeaders dittoHeadersWithETag = appendEntityIdAndETagToDittoHeaders(thingId, mergeThing, dittoHeaders); return MigrateThingDefinitionResponse.newInstance( thingId, - mergeThing.toJson(), + patch, MigrateThingDefinitionResponse.MergeStatus.APPLIED, HttpStatus.OK, dittoHeadersWithETag diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java index 899f217571..80ed103fba 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java @@ -13,7 +13,12 @@ package org.eclipse.ditto.things.service.persistence.actors.strategies.commands; import static org.eclipse.ditto.things.model.TestConstants.Thing.THING_V2; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; import com.typesafe.config.ConfigFactory; @@ -32,6 +37,7 @@ import org.eclipse.ditto.things.model.signals.events.ThingEvent; import org.eclipse.ditto.things.model.signals.events.ThingMigrated; import org.eclipse.ditto.things.service.persistence.actors.ETagTestUtils; +import org.eclipse.ditto.wot.api.generator.WotThingSkeletonGenerator; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -44,13 +50,47 @@ public final class MigrateThingDefinitionStrategyTest extends AbstractCommandStrategyTest { private MigrateThingDefinitionStrategy underTest; + private WotThingSkeletonGenerator mockWotThingSkeletonGenerator; @Before public void setUp() throws Exception { final ActorSystem actorSystem = ActorSystem.create("test", ConfigFactory.load("test")); + + mockWotThingSkeletonGenerator = mock(WotThingSkeletonGenerator.class); + underTest = new MigrateThingDefinitionStrategy(actorSystem); + + injectMock(underTest, "wotThingSkeletonGenerator", mockWotThingSkeletonGenerator); + + when(mockWotThingSkeletonGenerator.provideThingSkeletonForCreation(any(ThingId.class), any(), any(DittoHeaders.class))) + .thenReturn(CompletableFuture.completedFuture(Optional.of(createMockThingSkeleton()))); + } + + /** + * Injects a mock into a private field of the given object. + */ + private void injectMock(Object targetObject, String fieldName, Object mock) throws Exception { + Field field = null; + Class clazz = targetObject.getClass(); + + while (clazz != Object.class) { + try { + field = clazz.getDeclaredField(fieldName); + break; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + + if (field == null) { + throw new NoSuchFieldException("Field " + fieldName + " not found in class hierarchy."); + } + + field.setAccessible(true); + field.set(targetObject, mock); } + @Test public void migrateExistingThing() { final CommandStrategy.Context context = getDefaultContext(); @@ -61,11 +101,11 @@ public void migrateExistingThing() { .set("attributes", JsonFactory.newObjectBuilder().set("manufacturer", "New Corp").build()) .build(); - final String thingDefinitionUrl = "https://eclipse-ditto.github.io/ditto-examples/wot/models/dimmable-colored-lamp-1.0.0.tm.jsonld"; + final String mockThingDefinitionUrl = "http://mock-url-for-test.com/model.json"; final MigrateThingDefinition command = MigrateThingDefinition.of( thingId, - thingDefinitionUrl, + mockThingDefinitionUrl, migrationPayload, null, true, @@ -73,7 +113,13 @@ public void migrateExistingThing() { ); final MigrateThingDefinitionResponse expectedResponse = ETagTestUtils.migrateThingDefinitionResponse(thingId, - meregedThing(thingDefinitionUrl), + JsonFactory.newObjectBuilder() + .set("definition", mockThingDefinitionUrl) + .set("attributes", JsonFactory.newObjectBuilder() + .set("manufacturer", "New Corp") + .build()) + .build(), + createMockThingSkeleton(), command.getDittoHeaders()); final Result> result = underTest.apply(context, existingThing, NEXT_REVISION, command); @@ -86,19 +132,13 @@ public void migrateExistingThing() { assertStagedModificationResult(result, ThingMigrated.class, expectedResponse, false); } - - private Thing meregedThing(String thingDefinitionUrl) { + /** + * Creates a mock Thing skeleton to avoid real network calls. + */ + private Thing createMockThingSkeleton() { return ThingsModelFactory.newThingBuilder() - .setDefinition(ThingsModelFactory.newDefinition(thingDefinitionUrl)) .setAttributes(JsonFactory.newObjectBuilder() - .set("manufacturer", "New Corp") - .set("on", false) - .set("color", JsonFactory.newObjectBuilder() - .set("r", 0) - .set("g", 0) - .set("b", 0) - .build()) - .set("dimmer-level", 0.0) + .set("manufacturer", "MockCorp") .build()) .setRevision(ThingRevision.newInstance(NEXT_REVISION)) .build(); From 3c7fe359e84abf6107bf181b53dc275f673c401d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20J=C3=A4ckle?= Date: Mon, 10 Feb 2025 17:08:48 +0100 Subject: [PATCH 08/15] update `upload-artifact` action to v4 for system-tests.yml workflow --- .github/workflows/system-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/system-tests.yml b/.github/workflows/system-tests.yml index b092d02a72..695f644c7c 100644 --- a/.github/workflows/system-tests.yml +++ b/.github/workflows/system-tests.yml @@ -349,14 +349,14 @@ jobs: - name: Upload test results if: env.GITHUB_ACTOR != 'nektos/act' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: system-test-results-${{ env.DITTO_BRANCH_NO_SLASH }}-${{ github.run_number }} path: 'ditto-testing/system*/**/target/failsafe-reports/**/*.xml' - name: Upload services logs if: env.GITHUB_ACTOR != 'nektos/act' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: system-services-logs path: 'ditto-testing/docker/*.log' From db27293c79c668f6b2c39b8e436e568cfe1bfc97 Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Mon, 10 Feb 2025 20:35:08 +0100 Subject: [PATCH 09/15] #1843 add Migrated Command, enhance ThingMigrated Event --- .../base/model/signals/commands/Command.java | 5 + .../ThingMigratedEventSignalMapper.java | 2 +- .../ThingMigratedEventMappingStrategies.java | 5 +- .../modify/MigrateThingDefinition.java | 4 +- .../events/ThingEventToThingConverter.java | 11 +-- .../model/signals/events/ThingMigrated.java | 92 ++++++++----------- .../signals/events/ThingMigratedTest.java | 13 +-- .../MigrateThingDefinitionStrategy.java | 4 +- .../events/ThingMigratedStrategy.java | 2 +- 9 files changed, 56 insertions(+), 82 deletions(-) diff --git a/base/model/src/main/java/org/eclipse/ditto/base/model/signals/commands/Command.java b/base/model/src/main/java/org/eclipse/ditto/base/model/signals/commands/Command.java index c4277fa696..83f70a7636 100755 --- a/base/model/src/main/java/org/eclipse/ditto/base/model/signals/commands/Command.java +++ b/base/model/src/main/java/org/eclipse/ditto/base/model/signals/commands/Command.java @@ -150,6 +150,11 @@ enum Category { */ MERGE, + /** + * Category of commands that change the state of entities. + */ + MIGRATE, + /** * Category of commands that delete entities. */ diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigratedEventSignalMapper.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigratedEventSignalMapper.java index 5b83e6aa99..4e0d01a378 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigratedEventSignalMapper.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigratedEventSignalMapper.java @@ -29,7 +29,7 @@ final class ThingMigratedEventSignalMapper extends AbstractSignalMapper find(final String type) { private static ThingMigrated thingMigrated(final Adaptable adaptable) { return ThingMigrated.of( - thingIdFrom(adaptable), - JsonPointer.of(adaptable.getPayload().getPath().toString()), - adaptable.getPayload().getValue().orElse(null), + thingFrom(adaptable), revisionFrom(adaptable), timestampFrom(adaptable), dittoHeadersFrom(adaptable), diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java index c47548b9b5..d6f07d50b0 100644 --- a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java @@ -73,7 +73,7 @@ private MigrateThingDefinition(final ThingId thingId, final Map patchConditions, final boolean initializeMissingPropertiesFromDefaults, final DittoHeaders dittoHeaders) { - super(TYPE, FeatureToggle.checkMergeFeatureEnabled(TYPE, dittoHeaders)); + super(TYPE, dittoHeaders); this.thingId = checkNotNull(thingId, "thingId"); this.thingDefinitionUrl = checkNotNull(thingDefinitionUrl, "thingDefinitionUrl"); this.migrationPayload = checkJsonSize(checkNotNull(migrationPayload, "migrationPayload"), dittoHeaders); @@ -185,7 +185,7 @@ public boolean changesAuthorization() { @Override public Category getCategory() { - return Category.MODIFY; + return Category.MIGRATE; } @Override diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingEventToThingConverter.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingEventToThingConverter.java index db40d2faa9..3fd261c24e 100644 --- a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingEventToThingConverter.java +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingEventToThingConverter.java @@ -122,16 +122,7 @@ private static Map, BiFunction, ThingBuilder.FromScratch, } ); mappers.put(ThingMigrated.class, - (te, tb) -> { - final ThingMigrated thingMigrated = (ThingMigrated) te; - final JsonPointer resourcePath = thingMigrated.getResourcePath(); - final JsonValue nonNullValue = filterNullValuesInJsonValue(thingMigrated.getValue()); - final JsonObject mergedFields = JsonFactory.newObject(resourcePath, nonNullValue); - final JsonObject thingWithoutMergedFields = tb.build().toJson(FieldType.all()); - final JsonValue mergedJson = JsonFactory.mergeJsonValues(thingWithoutMergedFields, mergedFields); - return ThingsModelFactory.newThing(mergedJson.asObject()); - } - ); + (te, tb) -> ((ThingMigrated) te).getThing().toBuilder().setRevision(te.getRevision()).build()); mappers.put(ThingDeleted.class, (te, tb) -> tb.build()); diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java index 5bd595a05f..168c2c92a1 100644 --- a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java @@ -12,7 +12,6 @@ */ package org.eclipse.ditto.things.model.signals.events; -import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; import java.time.Instant; import java.util.Objects; @@ -27,7 +26,6 @@ import org.eclipse.ditto.base.model.json.FieldType; import org.eclipse.ditto.base.model.json.JsonParsableEvent; import org.eclipse.ditto.base.model.json.JsonSchemaVersion; -import org.eclipse.ditto.base.model.signals.FeatureToggle; import org.eclipse.ditto.base.model.signals.UnsupportedSchemaVersionException; import org.eclipse.ditto.base.model.signals.commands.Command; import org.eclipse.ditto.base.model.signals.events.EventJsonDeserializer; @@ -38,7 +36,8 @@ import org.eclipse.ditto.json.JsonObjectBuilder; import org.eclipse.ditto.json.JsonPointer; import org.eclipse.ditto.json.JsonValue; -import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.Thing; +import org.eclipse.ditto.things.model.ThingsModelFactory; /** * This event is emitted after a {@link org.eclipse.ditto.things.model.Thing} was successfully migrated. @@ -59,44 +58,34 @@ public final class ThingMigrated extends AbstractThingEvent imple */ public static final String TYPE = TYPE_PREFIX + NAME; - private final ThingId thingId; - private final JsonPointer path; - private final JsonValue value; + private final Thing thing; - private ThingMigrated(final ThingId thingId, - final JsonPointer path, - final JsonValue value, - final long revision, - @Nullable final Instant timestamp, - final DittoHeaders dittoHeaders, - @Nullable final Metadata metadata) { - super(TYPE, thingId, revision, timestamp, FeatureToggle.checkMergeFeatureEnabled(TYPE, dittoHeaders), metadata); - this.thingId = checkNotNull(thingId, "thingId"); - this.path = checkNotNull(path, "path"); - this.value = checkNotNull(value, "value"); + private ThingMigrated(final Thing thing, + final long revision, + @Nullable final Instant timestamp, + final DittoHeaders dittoHeaders, + @Nullable final Metadata metadata) { + super(TYPE, thing.getEntityId().orElseThrow(() -> new NullPointerException("Thing has no ID!")), revision, timestamp, dittoHeaders, metadata); + this.thing = thing; checkSchemaVersion(); } /** * Creates an event of a migrated thing. * - * @param thingId The thing ID. - * @param path The path where the changes were applied. - * @param value The value describing the changes that were migrated. + * @param thing the created {@link org.eclipse.ditto.things.model.Thing}. * @param revision The revision number of the thing. * @param timestamp The event timestamp. * @param dittoHeaders The Ditto headers. * @param metadata The metadata associated with the event. * @return The created {@code ThingMigrated} event. */ - public static ThingMigrated of(final ThingId thingId, - final JsonPointer path, - final JsonValue value, + public static ThingMigrated of(final Thing thing, final long revision, @Nullable final Instant timestamp, final DittoHeaders dittoHeaders, @Nullable final Metadata metadata) { - return new ThingMigrated(thingId, path, value, revision, timestamp, dittoHeaders, metadata); + return new ThingMigrated(thing, revision, timestamp, dittoHeaders, metadata); } /** @@ -109,25 +98,23 @@ public static ThingMigrated of(final ThingId thingId, public static ThingMigrated fromJson(final JsonObject jsonObject, final DittoHeaders dittoHeaders) { return new EventJsonDeserializer(TYPE, jsonObject).deserialize( (revision, timestamp, metadata) -> { - final ThingId thingId = ThingId.of(jsonObject.getValueOrThrow(ThingEvent.JsonFields.THING_ID)); - final JsonPointer path = JsonPointer.of(jsonObject.getValueOrThrow(JsonFields.JSON_PATH)); - final JsonValue value = jsonObject.getValueOrThrow(JsonFields.JSON_VALUE); + final JsonObject thingJsonObject = jsonObject.getValueOrThrow(ThingEvent.JsonFields.THING); + final Thing extractedThing = ThingsModelFactory.newThing(thingJsonObject); - return of(thingId, path, value, revision, timestamp, dittoHeaders, metadata); + return of(extractedThing, revision, timestamp, dittoHeaders, metadata); }); } @Override protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, - final JsonSchemaVersion schemaVersion, final Predicate predicate) { - jsonObjectBuilder.set(ThingEvent.JsonFields.THING_ID, thingId.toString(), predicate); - jsonObjectBuilder.set(JsonFields.JSON_PATH, path.toString(), predicate); - jsonObjectBuilder.set(JsonFields.JSON_VALUE, value, predicate); + final JsonSchemaVersion schemaVersion, final Predicate thePredicate) { + final Predicate predicate = schemaVersion.and(thePredicate); + jsonObjectBuilder.set(ThingEvent.JsonFields.THING, thing.toJson(schemaVersion, thePredicate), predicate); } @Override public ThingMigrated setDittoHeaders(final DittoHeaders dittoHeaders) { - return of(thingId, path, value, getRevision(), getTimestamp().orElse(null), dittoHeaders, + return of(thing, getRevision(), getTimestamp().orElse(null), dittoHeaders, getMetadata().orElse(null)); } @@ -138,28 +125,26 @@ public Command.Category getCommandCategory() { @Override public ThingMigrated setRevision(final long revision) { - return of(thingId, path, value, revision, getTimestamp().orElse(null), getDittoHeaders(), + return of(thing, revision, getTimestamp().orElse(null), getDittoHeaders(), getMetadata().orElse(null)); } - @Override - public JsonPointer getResourcePath() { - return path; - } - - public JsonValue getValue() { - return value; + /** + * @return the value describing the changes that were applied to the existing thing. + */ + public Thing getThing() { + return thing; } @Override - public Optional getEntity() { - return Optional.of(value); + public Optional getEntity(final JsonSchemaVersion schemaVersion) { + return Optional.of(thing.toJson(schemaVersion, FieldType.notHidden())); } @Override public ThingMigrated setEntity(final JsonValue entity) { - return of(thingId, path, entity, getRevision(), getTimestamp().orElse(null), getDittoHeaders(), - getMetadata().orElse(null)); + return of(ThingsModelFactory.newThing(entity.asObject()), getRevision(), getTimestamp().orElse(null), + getDittoHeaders(), getMetadata().orElse(null)); } @Override @@ -186,23 +171,25 @@ public boolean equals(final Object o) { return false; } final ThingMigrated that = (ThingMigrated) o; - return that.canEqual(this) && thingId.equals(that.thingId) && - path.equals(that.path) && - value.equals(that.value); + return that.canEqual(this) && thing.equals(that.thing); } @Override public int hashCode() { - return Objects.hash(super.hashCode(), thingId, path, value); + return Objects.hash(super.hashCode(), thing); } @Override public String toString() { return getClass().getSimpleName() + " [" + super.toString() + - ", path=" + path + - ", value=" + value + + ", thing=" + thing + "]"; } + @Override + public JsonPointer getResourcePath() { + return JsonPointer.empty(); + } + /** * An enumeration of the JSON fields of a {@code ThingMigrated} event. */ @@ -212,9 +199,6 @@ private JsonFields() { throw new AssertionError(); } - public static final JsonFieldDefinition JSON_PATH = - JsonFactory.newStringFieldDefinition("path", FieldType.REGULAR, JsonSchemaVersion.V_2); - public static final JsonFieldDefinition JSON_VALUE = JsonFactory.newJsonValueFieldDefinition("value", FieldType.REGULAR, JsonSchemaVersion.V_2); } diff --git a/things/model/src/test/java/org/eclipse/ditto/things/model/signals/events/ThingMigratedTest.java b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/events/ThingMigratedTest.java index a175a40a45..b6f7560977 100644 --- a/things/model/src/test/java/org/eclipse/ditto/things/model/signals/events/ThingMigratedTest.java +++ b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/events/ThingMigratedTest.java @@ -21,6 +21,7 @@ import org.eclipse.ditto.base.model.signals.events.EventsourcedEvent; import org.eclipse.ditto.json.JsonFactory; import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; import org.junit.Test; import nl.jqno.equalsverifier.EqualsVerifier; @@ -36,8 +37,7 @@ public final class ThingMigratedTest { .set(Event.JsonFields.METADATA, TestConstants.METADATA.toJson()) .set(EventsourcedEvent.JsonFields.REVISION, TestConstants.Thing.REVISION_NUMBER) .set(ThingEvent.JsonFields.THING_ID, TestConstants.Thing.THING_ID.toString()) - .set(ThingMigrated.JsonFields.JSON_PATH, TestConstants.Thing.ABSOLUTE_LOCATION_ATTRIBUTE_POINTER.toString()) - .set(ThingMigrated.JsonFields.JSON_VALUE, TestConstants.Thing.LOCATION_ATTRIBUTE_VALUE) + .set(ThingEvent.JsonFields.THING, TestConstants.Thing.THING.toJson()) .build(); /** @@ -60,14 +60,12 @@ public void testHashCodeAndEquals() { @Test public void toJsonReturnsExpected() { final ThingMigrated underTest = - ThingMigrated.of(TestConstants.Thing.THING_ID, - TestConstants.Thing.ABSOLUTE_LOCATION_ATTRIBUTE_POINTER, - TestConstants.Thing.LOCATION_ATTRIBUTE_VALUE, + ThingMigrated.of(TestConstants.Thing.THING, TestConstants.Thing.REVISION_NUMBER, TestConstants.TIMESTAMP, TestConstants.EMPTY_DITTO_HEADERS, TestConstants.METADATA); - final JsonObject actualJson = underTest.toJson(FieldType.regularOrSpecial()); + final JsonObject actualJson = underTest.toJson(FieldType.notHidden()); assertThat(actualJson).isEqualTo(KNOWN_JSON); } @@ -82,8 +80,7 @@ public void createInstanceFromValidJson() { assertThat(underTest).isNotNull(); assertThat((Object) underTest.getEntityId()).isEqualTo(TestConstants.Thing.THING_ID); - assertThat(underTest.getResourcePath()).isEqualTo(TestConstants.Thing.ABSOLUTE_LOCATION_ATTRIBUTE_POINTER); - assertThat(underTest.getValue()).isEqualTo(TestConstants.Thing.LOCATION_ATTRIBUTE_VALUE); + assertThat(underTest.getResourcePath()).isEqualTo(JsonPointer.empty()); assertThat(underTest.getMetadata()).contains(TestConstants.METADATA); assertThat(underTest.getRevision()).isEqualTo(TestConstants.Thing.REVISION_NUMBER); assertThat(underTest.getTimestamp()).contains(TestConstants.TIMESTAMP); diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java index 8149848baf..4c606e74f7 100644 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java @@ -117,7 +117,6 @@ private Result> handleMigrateDefinition( final DittoHeaders dittoHeaders = command.getDittoHeaders(); final boolean isDryRun = dittoHeaders.isDryRun(); - final JsonPointer path = JsonPointer.empty(); // 1. Evaluate Patch Conditions and modify the migrationPayload final JsonObject adjustedMigrationPayload = evaluatePatchConditions( @@ -153,7 +152,7 @@ private Result> handleMigrateDefinition( // 4. Apply migration and generate event final CompletionStage> eventStage = validatedStage.thenApply(pair -> ThingMigrated.of( - pair.second().getEntityId(), path, pair.first().toJson(), nextRevision, eventTs, dittoHeaders, + pair.first(), nextRevision, eventTs, dittoHeaders, metadata)); final CompletionStage responseStage = validatedStage.thenApply(pair -> @@ -337,6 +336,7 @@ private Thing applyMigrationPayload(final Context context, final Thing () -> dittoHeaders); return ThingsModelFactory.newThingBuilder(mergedJson) + .setId(context.getState()) .setModified(eventTs) .setRevision(nextRevision) .build(); diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingMigratedStrategy.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingMigratedStrategy.java index 20e2063d6e..dda0d26284 100644 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingMigratedStrategy.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingMigratedStrategy.java @@ -38,7 +38,7 @@ final class ThingMigratedStrategy extends AbstractThingEventStrategy Date: Mon, 10 Feb 2025 22:37:03 +0100 Subject: [PATCH 10/15] #1843 modify command category --- .../model/signals/events/ThingMigrated.java | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java index 168c2c92a1..0ddeeb7999 100644 --- a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java @@ -120,7 +120,7 @@ public ThingMigrated setDittoHeaders(final DittoHeaders dittoHeaders) { @Override public Command.Category getCommandCategory() { - return Command.Category.MODIFY; + return Command.Category.MIGRATE; } @Override @@ -190,17 +190,4 @@ public JsonPointer getResourcePath() { return JsonPointer.empty(); } - /** - * An enumeration of the JSON fields of a {@code ThingMigrated} event. - */ - public static final class JsonFields { - - private JsonFields() { - throw new AssertionError(); - } - - public static final JsonFieldDefinition JSON_VALUE = - JsonFactory.newJsonValueFieldDefinition("value", FieldType.REGULAR, JsonSchemaVersion.V_2); - } - } From a5946a81dcaa35f2b6023f08ef321f3b7c8bf377 Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Mon, 10 Feb 2025 23:26:13 +0100 Subject: [PATCH 11/15] fix test --- .../adapter/things/ThingMigratedEventAdapterTest.java | 11 ++++++----- .../things/model/signals/events/ThingMigrated.java | 2 -- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapterTest.java b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapterTest.java index e770487930..d022a60379 100644 --- a/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapterTest.java +++ b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapterTest.java @@ -17,8 +17,9 @@ import java.time.Instant; +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.json.JsonObject; import org.eclipse.ditto.json.JsonPointer; -import org.eclipse.ditto.json.JsonValue; import org.eclipse.ditto.base.model.headers.DittoHeaderDefinition; import org.eclipse.ditto.base.model.headers.DittoHeaders; import org.eclipse.ditto.base.model.headers.contenttype.ContentType; @@ -66,12 +67,12 @@ public void unknownCommandFromAdaptable() { @Test public void thingMigratedFromAdaptable() { final JsonPointer path = TestConstants.THING_POINTER; - final JsonValue value = TestConstants.THING.toJson(); + final JsonObject value = TestConstants.THING.toJson(FieldType.all()); final long revision = TestConstants.REVISION; final Instant now = Instant.now(); final ThingMigrated expected = - ThingMigrated.of(TestConstants.THING_ID, path, value, + ThingMigrated.of(TestConstants.THING, revision, now, setChannelHeader(TestConstants.DITTO_HEADERS_V_2), null); final Adaptable adaptable = Adaptable.newBuilder(topicPathMigrated()) @@ -90,7 +91,7 @@ public void thingMigratedFromAdaptable() { @Test public void thingMigratedToAdaptable() { final JsonPointer path = TestConstants.THING_POINTER; - final JsonValue value = TestConstants.THING.toJson(); + final JsonObject value = TestConstants.THING.toJson(); final long revision = TestConstants.REVISION; final Instant now = Instant.now(); @@ -104,7 +105,7 @@ public void thingMigratedToAdaptable() { .build(); final ThingMigrated thingMigrated = - ThingMigrated.of(TestConstants.THING_ID, path, value, + ThingMigrated.of(TestConstants.THING, revision, now, setChannelHeader(TestConstants.DITTO_HEADERS_V_2), null); final Adaptable actual = underTest.toAdaptable(thingMigrated, channel); diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java index 0ddeeb7999..68fc627c93 100644 --- a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java @@ -29,9 +29,7 @@ import org.eclipse.ditto.base.model.signals.UnsupportedSchemaVersionException; import org.eclipse.ditto.base.model.signals.commands.Command; import org.eclipse.ditto.base.model.signals.events.EventJsonDeserializer; -import org.eclipse.ditto.json.JsonFactory; import org.eclipse.ditto.json.JsonField; -import org.eclipse.ditto.json.JsonFieldDefinition; import org.eclipse.ditto.json.JsonObject; import org.eclipse.ditto.json.JsonObjectBuilder; import org.eclipse.ditto.json.JsonPointer; From 95d687ba8a43818856a090e1b425f5f365e5298f Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Mon, 10 Feb 2025 23:51:32 +0100 Subject: [PATCH 12/15] fix test --- .../strategies/commands/MigrateThingDefinitionStrategyTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java index 80ed103fba..a5db7884a5 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java @@ -114,6 +114,7 @@ public void migrateExistingThing() { final MigrateThingDefinitionResponse expectedResponse = ETagTestUtils.migrateThingDefinitionResponse(thingId, JsonFactory.newObjectBuilder() + .set("thingId", thingId.toString()) .set("definition", mockThingDefinitionUrl) .set("attributes", JsonFactory.newObjectBuilder() .set("manufacturer", "New Corp") From fdd51b498c68747ace3804b03c20f6baac3b35c3 Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Tue, 11 Feb 2025 19:33:04 +0100 Subject: [PATCH 13/15] resolve minor comments --- .../ditto/things/model/ThingBuilder.java | 4 +- .../model/signals/events/ThingMigrated.java | 7 +--- .../MigrateThingDefinitionStrategy.java | 37 ++++++++++--------- 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/ThingBuilder.java b/things/model/src/main/java/org/eclipse/ditto/things/model/ThingBuilder.java index 8d29c9337a..4726f4e1b4 100755 --- a/things/model/src/main/java/org/eclipse/ditto/things/model/ThingBuilder.java +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/ThingBuilder.java @@ -195,8 +195,8 @@ interface FromScratch { * @return this builder to allow method chaining. * @throws NullPointerException if {@code featureId} is {@code null}. */ - FromScratch setFeature(String featureId, FeatureDefinition featureDefinition, - FeatureProperties featureProperties); + FromScratch setFeature(String featureId, @Nullable FeatureDefinition featureDefinition, + @Nullable FeatureProperties featureProperties); /** * Sets a Feature with the given ID and properties to this builder. A previously set Feature with the diff --git a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java index 68fc627c93..d9089f5842 100644 --- a/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java @@ -63,7 +63,7 @@ private ThingMigrated(final Thing thing, @Nullable final Instant timestamp, final DittoHeaders dittoHeaders, @Nullable final Metadata metadata) { - super(TYPE, thing.getEntityId().orElseThrow(() -> new NullPointerException("Thing has no ID!")), revision, timestamp, dittoHeaders, metadata); + super(TYPE, thing.getEntityId().orElseThrow(() -> new IllegalArgumentException("Thing has no ID!")), revision, timestamp, dittoHeaders, metadata); this.thing = thing; checkSchemaVersion(); } @@ -145,11 +145,6 @@ public ThingMigrated setEntity(final JsonValue entity) { getDittoHeaders(), getMetadata().orElse(null)); } - @Override - public JsonSchemaVersion[] getSupportedSchemaVersions() { - return new JsonSchemaVersion[]{JsonSchemaVersion.V_2}; - } - private void checkSchemaVersion() { final JsonSchemaVersion implementedSchemaVersion = getImplementedSchemaVersion(); if (!implementsSchemaVersion(implementedSchemaVersion)) { diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java index 4c606e74f7..6dc40efd4a 100644 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java @@ -44,6 +44,7 @@ import org.eclipse.ditto.rql.parser.RqlPredicateParser; import org.eclipse.ditto.rql.query.filter.QueryFilterCriteriaFactory; import org.eclipse.ditto.rql.query.things.ThingPredicateVisitor; +import org.eclipse.ditto.things.model.FeatureDefinition; import org.eclipse.ditto.things.model.Thing; import org.eclipse.ditto.things.model.ThingId; import org.eclipse.ditto.things.model.ThingsModelFactory; @@ -144,7 +145,7 @@ private Result> handleMigrateDefinition( command, validatedStage.thenApply(pair -> MigrateThingDefinitionResponse.dryRun( - existingThing.getEntityId().get(), + context.getState(), pair.first().toJson(), dittoHeaders)) ); @@ -156,7 +157,7 @@ private Result> handleMigrateDefinition( metadata)); final CompletionStage responseStage = validatedStage.thenApply(pair -> - appendETagHeaderIfProvided(command, MigrateThingDefinitionResponse.applied(existingThing.getEntityId().get(), + appendETagHeaderIfProvided(command, MigrateThingDefinitionResponse.applied(context.getState(), pair.first().toJson(), dittoHeaders), pair.first())); @@ -235,8 +236,11 @@ private CompletionStage generateSkeleton( private Thing extractDefinitions(final Thing thing) { var thingBuilder = ThingsModelFactory.newThingBuilder(); - thing.getFeatures().orElseGet(ThingsModelFactory::emptyFeatures).forEach(feature -> - thingBuilder.setFeature(feature.getId(), feature.getDefinition().get(), null)); + thing.getFeatures().orElseGet(ThingsModelFactory::emptyFeatures).forEach(feature -> { + FeatureDefinition featureDefinition = feature.getDefinition().orElse(null); + thingBuilder.setFeature(feature.getId(), featureDefinition, null); + }); + return thingBuilder.build(); } @@ -291,15 +295,11 @@ public static JsonObject removeConflicts(final JsonObject defaultValues, final J continue; } - if (maybeExistingValue.isPresent()) { - JsonValue resolvedValue = resolveConflictingValues(defaultValue, maybeExistingValue.get()); - if (resolvedValue != null) { - builder.set(key, resolvedValue); - } - } - else { - builder.set(field); - } + maybeExistingValue.flatMap(existingValue -> resolveConflictingValues(defaultValue, existingValue)) + .ifPresentOrElse( + resolvedValue -> builder.set(key, resolvedValue), + () -> builder.set(field) + ); } return builder.build(); @@ -308,20 +308,21 @@ public static JsonObject removeConflicts(final JsonObject defaultValues, final J /** * Resolves conflicting JsonValue objects by recursively comparing them. * If both values are JsonObjects, it calls {@link #removeConflicts(JsonObject, JsonObject)} - * to recursively filter out conflicting values. Otherwise, it returns null, + * to recursively filter out conflicting values. Otherwise, it returns an empty Optional, * indicating that the value should not be retained. * * @param defaultValue The JsonValue from the default values object. * @param existingValue The JsonValue from the existing values object. - * @return A filtered JsonObject if both values are objects; otherwise, null. + * @return An Optional containing a filtered JsonObject if both values are objects; otherwise, an empty Optional. */ - private static JsonValue resolveConflictingValues(final JsonValue defaultValue, final JsonValue existingValue) { + private static Optional resolveConflictingValues(final JsonValue defaultValue, final JsonValue existingValue) { return (defaultValue.isObject() && existingValue.isObject()) - ? removeConflicts(defaultValue.asObject(), existingValue.asObject()) - : null; + ? Optional.of(removeConflicts(defaultValue.asObject(), existingValue.asObject())) + : Optional.empty(); } + private Thing applyMigrationPayload(final Context context, final Thing thing, final JsonObject migrationPayload, final DittoHeaders dittoHeaders, From 3b8d4cfac5233d2a6b36afe534724afe6cb5a6cb Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Wed, 12 Feb 2025 12:49:02 +0100 Subject: [PATCH 14/15] remove duplicate id in the response --- .../strategies/commands/MigrateThingDefinitionStrategy.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java index 6dc40efd4a..3aaa379432 100644 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java @@ -153,7 +153,9 @@ private Result> handleMigrateDefinition( // 4. Apply migration and generate event final CompletionStage> eventStage = validatedStage.thenApply(pair -> ThingMigrated.of( - pair.first(), nextRevision, eventTs, dittoHeaders, + pair.first().toBuilder() + .setId(context.getState()) + .build(), nextRevision, eventTs, dittoHeaders, metadata)); final CompletionStage responseStage = validatedStage.thenApply(pair -> @@ -337,7 +339,6 @@ private Thing applyMigrationPayload(final Context context, final Thing () -> dittoHeaders); return ThingsModelFactory.newThingBuilder(mergedJson) - .setId(context.getState()) .setModified(eventTs) .setRevision(nextRevision) .build(); From 91cd00bac8ee206f76fa97cff08017fb851f2e1a Mon Sep 17 00:00:00 2001 From: Hussein Ahmed Date: Wed, 12 Feb 2025 13:24:40 +0100 Subject: [PATCH 15/15] remove mock from MigrateThingDefinitionStrategyTest --- .../src/main/resources/things-dev.conf | 2 +- .../MigrateThingDefinitionStrategyTest.java | 81 +++++-------------- 2 files changed, 23 insertions(+), 60 deletions(-) diff --git a/things/service/src/main/resources/things-dev.conf b/things/service/src/main/resources/things-dev.conf index 9f7d77d3c0..61652919ac 100755 --- a/things/service/src/main/resources/things-dev.conf +++ b/things/service/src/main/resources/things-dev.conf @@ -56,7 +56,7 @@ ditto { wot { tm-model-validation { - enabled = true + enabled = false dynamic-configuration = [ { diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java index a5db7884a5..7ceeadeee5 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java @@ -13,12 +13,7 @@ package org.eclipse.ditto.things.service.persistence.actors.strategies.commands; import static org.eclipse.ditto.things.model.TestConstants.Thing.THING_V2; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; -import java.lang.reflect.Field; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; import com.typesafe.config.ConfigFactory; @@ -37,57 +32,20 @@ import org.eclipse.ditto.things.model.signals.events.ThingEvent; import org.eclipse.ditto.things.model.signals.events.ThingMigrated; import org.eclipse.ditto.things.service.persistence.actors.ETagTestUtils; -import org.eclipse.ditto.wot.api.generator.WotThingSkeletonGenerator; import org.junit.Before; import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.junit.MockitoJUnitRunner; /** * Unit test for {@link MigrateThingDefinitionStrategy} with injected mock of WotThingSkeletonGenerator. */ -@RunWith(MockitoJUnitRunner.class) public final class MigrateThingDefinitionStrategyTest extends AbstractCommandStrategyTest { private MigrateThingDefinitionStrategy underTest; - private WotThingSkeletonGenerator mockWotThingSkeletonGenerator; @Before public void setUp() throws Exception { final ActorSystem actorSystem = ActorSystem.create("test", ConfigFactory.load("test")); - - mockWotThingSkeletonGenerator = mock(WotThingSkeletonGenerator.class); - underTest = new MigrateThingDefinitionStrategy(actorSystem); - - injectMock(underTest, "wotThingSkeletonGenerator", mockWotThingSkeletonGenerator); - - when(mockWotThingSkeletonGenerator.provideThingSkeletonForCreation(any(ThingId.class), any(), any(DittoHeaders.class))) - .thenReturn(CompletableFuture.completedFuture(Optional.of(createMockThingSkeleton()))); - } - - /** - * Injects a mock into a private field of the given object. - */ - private void injectMock(Object targetObject, String fieldName, Object mock) throws Exception { - Field field = null; - Class clazz = targetObject.getClass(); - - while (clazz != Object.class) { - try { - field = clazz.getDeclaredField(fieldName); - break; - } catch (NoSuchFieldException e) { - clazz = clazz.getSuperclass(); - } - } - - if (field == null) { - throw new NoSuchFieldException("Field " + fieldName + " not found in class hierarchy."); - } - - field.setAccessible(true); - field.set(targetObject, mock); } @@ -101,11 +59,11 @@ public void migrateExistingThing() { .set("attributes", JsonFactory.newObjectBuilder().set("manufacturer", "New Corp").build()) .build(); - final String mockThingDefinitionUrl = "http://mock-url-for-test.com/model.json"; + final String thingDefinitionUrl = "https://eclipse-ditto.github.io/ditto-examples/wot/models/dimmable-colored-lamp-1.0.0.tm.jsonld"; final MigrateThingDefinition command = MigrateThingDefinition.of( thingId, - mockThingDefinitionUrl, + thingDefinitionUrl, migrationPayload, null, true, @@ -113,14 +71,8 @@ public void migrateExistingThing() { ); final MigrateThingDefinitionResponse expectedResponse = ETagTestUtils.migrateThingDefinitionResponse(thingId, - JsonFactory.newObjectBuilder() - .set("thingId", thingId.toString()) - .set("definition", mockThingDefinitionUrl) - .set("attributes", JsonFactory.newObjectBuilder() - .set("manufacturer", "New Corp") - .build()) - .build(), - createMockThingSkeleton(), + getThingJson(thingDefinitionUrl), + getMergedThing(thingDefinitionUrl), command.getDittoHeaders()); final Result> result = underTest.apply(context, existingThing, NEXT_REVISION, command); @@ -133,14 +85,25 @@ public void migrateExistingThing() { assertStagedModificationResult(result, ThingMigrated.class, expectedResponse, false); } - /** - * Creates a mock Thing skeleton to avoid real network calls. - */ - private Thing createMockThingSkeleton() { - return ThingsModelFactory.newThingBuilder() - .setAttributes(JsonFactory.newObjectBuilder() - .set("manufacturer", "MockCorp") + private static JsonObject getThingJson(String thingDefinitionUrl) { + return JsonFactory.newObjectBuilder() + .set("definition", thingDefinitionUrl) + .set("attributes", JsonFactory.newObjectBuilder() + .set("manufacturer", "New Corp") + .set("on", false) + .set("color", JsonFactory.newObjectBuilder() + .set("r", 0) + .set("g", 0) + .set("b", 0) + .build()) + .set("dimmer-level", 0.0) .build()) + .build(); + } + + + private Thing getMergedThing(final String thingDefinitionUrl) { + return ThingsModelFactory.newThingBuilder(getThingJson(thingDefinitionUrl)) .setRevision(ThingRevision.newInstance(NEXT_REVISION)) .build(); }