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/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..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,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/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..bf920f0659 --- /dev/null +++ b/documentation/src/main/resources/openapi/sources/paths/things/migrateDefinition.yml @@ -0,0 +1,105 @@ +# 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. + + If the `dry-run` query parameter is set to `true`, the request will return the calculated migration result without applying any changes. + + ### 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' + - 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 + content: + application/json: + schema: + $ref: '../../schemas/things/migrateThingDefinitionRequest.yml' + responses: + '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: + 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/schemas/things/migrateThingDefinitionRequest.yml b/documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionRequest.yml new file mode 100644 index 0000000000..56be103fec --- /dev/null +++ b/documentation/src/main/resources/openapi/sources/schemas/things/migrateThingDefinitionRequest.yml @@ -0,0 +1,64 @@ +# 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 + default: false + +required: + - thingDefinitionUrl 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/documentation/src/main/resources/pages/ditto/httpapi-concepts.md b/documentation/src/main/resources/pages/ditto/httpapi-concepts.md index 81682f7d28..c1fc34b385 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 - 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. + +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..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 @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017 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. @@ -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,34 @@ 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?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); + return MigrateThingDefinition.fromJson(updatedJson, dittoHeaders.toBuilder() + .putHeader(DittoHeaderDefinition.DRY_RUN.getKey(),dryRun.orElse(Boolean.FALSE.toString())) + .build()); + }) + ) + )) + ); + } + + 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()) + .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..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 @@ -154,6 +154,44 @@ public void putAndRetrieveNullDefinition() { getResult.assertStatusCode(StatusCodes.OK); } + @Test + public void postMigrateThingDefinitionSuccessfully() { + 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/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/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/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 8bcc89c638..9117c84a7a 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; @@ -503,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 6fa1c1f090..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,15 +407,19 @@ enum Action { DELETE("delete"), + MIGRATE("migrate"), CREATED("created"), MODIFIED("modified"), MERGED("merged"), + MIGRATED("migrated"), + DELETED("deleted"); + private final String name; Action(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..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 @@ -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; @@ -54,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; @@ -115,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(); @@ -230,6 +236,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/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/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/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 e8266a94ad..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 @@ -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. @@ -36,7 +36,9 @@ public interface ThingCommandAdapterProvider SearchErrorResponseAdapterProvider, EventAdapterProvider>, MergeEventAdapterProvider, + MigrateEventAdapterProvider, 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..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 @@ -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; @@ -33,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; @@ -49,11 +51,13 @@ 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; private final ThingEventAdapter thingEventAdapter; private final ThingMergedEventAdapter thingMergedEventAdapter; + private final ThingMigratedEventAdapter thingMigratedEventAdapter; private final SubscriptionEventAdapter subscriptionEventAdapter; private final ThingErrorResponseAdapter errorResponseAdapter; private final RetrieveThingsCommandAdapter retrieveThingsCommandAdapter; @@ -70,11 +74,13 @@ public DefaultThingCommandAdapterProvider(final ErrorRegistry> getAdapters() { queryCommandResponseAdapter, modifyCommandResponseAdapter, mergeCommandResponseAdapter, + migrateCommandAdapter, messageCommandAdapter, messageCommandResponseAdapter, thingEventAdapter, thingMergedEventAdapter, + thingMigratedEventAdapter, searchCommandAdapter, subscriptionEventAdapter, errorResponseAdapter, @@ -116,6 +124,9 @@ public Adapter> getEventAdapter() { public Adapter getMergedEventAdapter() { return thingMergedEventAdapter; } + public Adapter getMigratedEventAdapter() { + return thingMigratedEventAdapter; + } @Override public Adapter> getSubscriptionEventAdapter() { @@ -132,6 +143,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/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/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..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(); } @@ -103,6 +108,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/mapper/ThingMigratedEventSignalMapper.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/ThingMigratedEventSignalMapper.java new file mode 100644 index 0000000000..4e0d01a378 --- /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.getThing().toJson()); + } + + @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 22b7abf4fd..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 @@ -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(); } @@ -91,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/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/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..b4a1662b03 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/ThingMigratedEventMappingStrategies.java @@ -0,0 +1,72 @@ +/* + * 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.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( + thingFrom(adaptable), + 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/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/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..d022a60379 --- /dev/null +++ b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/ThingMigratedEventAdapterTest.java @@ -0,0 +1,142 @@ +/* + * 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.base.model.json.FieldType; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +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 JsonObject value = TestConstants.THING.toJson(FieldType.all()); + final long revision = TestConstants.REVISION; + + final Instant now = Instant.now(); + final ThingMigrated expected = + ThingMigrated.of(TestConstants.THING, + 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 JsonObject 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, + 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/ThingBuilder.java b/things/model/src/main/java/org/eclipse/ditto/things/model/ThingBuilder.java index 5bb1e0f679..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,7 +195,7 @@ interface FromScratch { * @return this builder to allow method chaining. * @throws NullPointerException if {@code featureId} is {@code null}. */ - FromScratch setFeature(String featureId, FeatureDefinition featureDefinition, + FromScratch setFeature(String featureId, @Nullable FeatureDefinition featureDefinition, @Nullable FeatureProperties featureProperties); /** 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..a312e94ae2 --- /dev/null +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/exceptions/SkeletonGenerationFailedException.java @@ -0,0 +1,123 @@ +/* + * 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.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 new file mode 100644 index 0000000000..d6f07d50b0 --- /dev/null +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinition.java @@ -0,0 +1,287 @@ +/* + * 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; + +/** + * 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, 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; + checkSchemaVersion(); + } + + /** + * 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 initializeMissingPropertiesFromDefaults 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 initializeMissingPropertiesFromDefaults, + final DittoHeaders dittoHeaders) { + return new MigrateThingDefinition(thingId, thingDefinitionUrl, migrationPayload, + patchConditions, initializeMissingPropertiesFromDefaults, dittoHeaders); + } + + /** + * 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 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.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() + .collect(Collectors.toMap( + field -> ResourceKey.newInstance(field.getKey()), + field -> field.getValue().asString() + )); + + final boolean initializeMissingPropertiesFromDefaults = jsonObject.getValue(JsonFields.JSON_INITIALIZE_MISSING_PROPERTIES_FROM_DEFAULTS) + .orElse(false); + + return new MigrateThingDefinition( + ThingId.of(thingIdStr), + thingDefinitionUrl, + migrationPayload, + patchConditions, + initializeMissingPropertiesFromDefaults, + 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.MIGRATE; + } + + @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); + } + } + + @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 MigrateThingDefinition} command. + */ + @Immutable + public static final class JsonFields { + + public static final JsonFieldDefinition JSON_THING_DEFINITION_URL = + JsonFactory.newStringFieldDefinition("thingDefinitionUrl", FieldType.REGULAR, JsonSchemaVersion.V_2); + + public static final JsonFieldDefinition JSON_MIGRATION_PAYLOAD = + JsonFactory.newJsonObjectFieldDefinition("migrationPayload", FieldType.REGULAR, JsonSchemaVersion.V_2); + + public static final JsonFieldDefinition JSON_PATCH_CONDITIONS = + JsonFactory.newJsonObjectFieldDefinition("patchConditions", FieldType.REGULAR, JsonSchemaVersion.V_2); + + public 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 "MigrateThingDefinition{" + + "thingId=" + thingId + + ", thingDefinitionUrl='" + thingDefinitionUrl + '\'' + + ", migrationPayload=" + migrationPayload + + ", patchConditions=" + patchConditions + + ", initializeMissingPropertiesFromDefaults=" + initializeMissingPropertiesFromDefaults + + ", dittoHeaders=" + getDittoHeaders() + + '}'; + } +} 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..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 @@ -121,6 +121,8 @@ private static Map, BiFunction, ThingBuilder.FromScratch, return ThingsModelFactory.newThing(mergedJson.asObject()); } ); + mappers.put(ThingMigrated.class, + (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 new file mode 100644 index 0000000000..d9089f5842 --- /dev/null +++ b/things/model/src/main/java/org/eclipse/ditto/things/model/signals/events/ThingMigrated.java @@ -0,0 +1,186 @@ +/* + * 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 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.UnsupportedSchemaVersionException; +import org.eclipse.ditto.base.model.signals.commands.Command; +import org.eclipse.ditto.base.model.signals.events.EventJsonDeserializer; +import org.eclipse.ditto.json.JsonField; +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.Thing; +import org.eclipse.ditto.things.model.ThingsModelFactory; + +/** + * 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 Thing thing; + + 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 IllegalArgumentException("Thing has no ID!")), revision, timestamp, dittoHeaders, metadata); + this.thing = thing; + checkSchemaVersion(); + } + + /** + * Creates an event of a migrated thing. + * + * @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 Thing thing, + final long revision, + @Nullable final Instant timestamp, + final DittoHeaders dittoHeaders, + @Nullable final Metadata metadata) { + return new ThingMigrated(thing, 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 JsonObject thingJsonObject = jsonObject.getValueOrThrow(ThingEvent.JsonFields.THING); + final Thing extractedThing = ThingsModelFactory.newThing(thingJsonObject); + + return of(extractedThing, revision, timestamp, dittoHeaders, metadata); + }); + } + + @Override + protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, + 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(thing, getRevision(), getTimestamp().orElse(null), dittoHeaders, + getMetadata().orElse(null)); + } + + @Override + public Command.Category getCommandCategory() { + return Command.Category.MIGRATE; + } + + @Override + public ThingMigrated setRevision(final long revision) { + return of(thing, revision, getTimestamp().orElse(null), getDittoHeaders(), + getMetadata().orElse(null)); + } + + /** + * @return the value describing the changes that were applied to the existing thing. + */ + public Thing getThing() { + return thing; + } + + @Override + public Optional getEntity(final JsonSchemaVersion schemaVersion) { + return Optional.of(thing.toJson(schemaVersion, FieldType.notHidden())); + } + + @Override + public ThingMigrated setEntity(final JsonValue entity) { + return of(ThingsModelFactory.newThing(entity.asObject()), getRevision(), getTimestamp().orElse(null), + getDittoHeaders(), getMetadata().orElse(null)); + } + + 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) && thing.equals(that.thing); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), thing); + } + @Override + public String toString() { + return getClass().getSimpleName() + " [" + super.toString() + + ", thing=" + thing + + "]"; + } + + @Override + public JsonPointer getResourcePath() { + return JsonPointer.empty(); + } + +} 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/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..a81ea7a1a9 --- /dev/null +++ b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/commands/modify/MigrateThingDefinitionTest.java @@ -0,0 +1,179 @@ +/* + * 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("initializeMissingPropertiesFromDefaults", 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 + 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("initializeMissingPropertiesFromDefaults"))).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/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..b6f7560977 --- /dev/null +++ b/things/model/src/test/java/org/eclipse/ditto/things/model/signals/events/ThingMigratedTest.java @@ -0,0 +1,88 @@ +/* + * 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.eclipse.ditto.json.JsonPointer; +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(ThingEvent.JsonFields.THING, TestConstants.Thing.THING.toJson()) + .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, + TestConstants.Thing.REVISION_NUMBER, + TestConstants.TIMESTAMP, + TestConstants.EMPTY_DITTO_HEADERS, + TestConstants.METADATA); + final JsonObject actualJson = underTest.toJson(FieldType.notHidden()); + + 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(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 new file mode 100644 index 0000000000..3aaa379432 --- /dev/null +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java @@ -0,0 +1,367 @@ +/* + * 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.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.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; +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; +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; +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.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; + + +/** + * 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(); + + /** + * 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); + } + + @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 handleMigrateDefinition(context, existingThing, eventTs, nextRevision, command, metadata); + } + + private Result> handleMigrateDefinition( + final Context context, + final Thing existingThing, + final Instant eventTs, + final long nextRevision, + final MigrateThingDefinition command, + @Nullable final Metadata metadata) { + + final DittoHeaders dittoHeaders = command.getDittoHeaders(); + final boolean isDryRun = dittoHeaders.isDryRun(); + + // 1. Evaluate Patch Conditions and modify the migrationPayload + final JsonObject adjustedMigrationPayload = evaluatePatchConditions( + existingThing, + command.getMigrationPayload(), + command.getPatchConditions(), + dittoHeaders); + + // 2. Generate Skeleton using definition and apply migration + final CompletionStage updatedThingStage = generateSkeleton(command, dittoHeaders) + .thenApply(skeleton -> resolveSkeletonConflicts( + existingThing, skeleton, + command.isInitializeMissingPropertiesFromDefaults())) + .thenApply(mergedThing -> applyMigrationPayload(context, + 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))); + + // If Dry Run, return a simulated response without applying changes + if (isDryRun) { + return ResultFactory.newQueryResult( + command, + validatedStage.thenApply(pair -> + MigrateThingDefinitionResponse.dryRun( + context.getState(), + pair.first().toJson(), + dittoHeaders)) + ); + } + + // 4. Apply migration and generate event + final CompletionStage> eventStage = validatedStage.thenApply(pair -> ThingMigrated.of( + pair.first().toBuilder() + .setId(context.getState()) + .build(), nextRevision, eventTs, dittoHeaders, + metadata)); + + final CompletionStage responseStage = validatedStage.thenApply(pair -> + appendETagHeaderIfProvided(command, MigrateThingDefinitionResponse.applied(context.getState(), + pair.first().toJson(), dittoHeaders), + pair.first())); + + return ResultFactory.newMutationResult(command, eventStage, responseStage); + } + + private JsonObject evaluatePatchConditions(final Thing existingThing, + final JsonObject migrationPayload, + final Map patchConditions, + final DittoHeaders dittoHeaders) { + final JsonObjectBuilder adjustedPayloadBuilder = migrationPayload.toBuilder(); + + for (final 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(); + } + + 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) { + 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 (final ParserException | IllegalArgumentException e) { + throw InvalidRqlExpressionException.newBuilder() + .message(e.getMessage()) + .cause(e) + .dittoHeaders(dittoHeaders) + .build(); + } + } + + private CompletionStage generateSkeleton( + final MigrateThingDefinition command, + final DittoHeaders dittoHeaders) { + return wotThingSkeletonGenerator.provideThingSkeletonForCreation( + command.getEntityId(), + ThingsModelFactory.newDefinition(command.getThingDefinitionUrl()), + dittoHeaders + ) + .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) { + var thingBuilder = ThingsModelFactory.newThingBuilder(); + thing.getFeatures().orElseGet(ThingsModelFactory::emptyFeatures).forEach(feature -> { + FeatureDefinition featureDefinition = feature.getDefinition().orElse(null); + thingBuilder.setFeature(feature.getId(), featureDefinition, 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); + } + + + /** + * 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(); + + if (defaultValues.isNull() && existingValues.isNull()) { + return JsonFactory.nullObject(); + } + + for (JsonField field : defaultValues) { + final JsonKey key = field.getKey(); + final JsonValue defaultValue = field.getValue(); + final Optional maybeExistingValue = existingValues.getValue(key); + + if (key.toString().contains("definition")) { + builder.set(key, defaultValue); + continue; + } + + maybeExistingValue.flatMap(existingValue -> resolveConflictingValues(defaultValue, existingValue)) + .ifPresentOrElse( + resolvedValue -> builder.set(key, resolvedValue), + () -> builder.set(field) + ); + } + + 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 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 An Optional containing a filtered JsonObject if both values are objects; otherwise, an empty Optional. + */ + private static Optional resolveConflictingValues(final JsonValue defaultValue, final JsonValue existingValue) { + return (defaultValue.isObject() && existingValue.isObject()) + ? Optional.of(removeConflicts(defaultValue.asObject(), existingValue.asObject())) + : Optional.empty(); + } + + + + 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); + context.getLog().debug("Thing updated from migrated JSON: {}", mergedJson); + 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..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) { 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..dda0d26284 --- /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 = event.getThing().toJson(); + 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/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/ETagTestUtils.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java index 80f9c58f9f..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 @@ -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..7ceeadeee5 --- /dev/null +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java @@ -0,0 +1,111 @@ +/* + * 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 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.junit.Before; +import org.junit.Test; + +/** + * Unit test for {@link MigrateThingDefinitionStrategy} with injected mock of WotThingSkeletonGenerator. + */ +public final class MigrateThingDefinitionStrategyTest extends AbstractCommandStrategyTest { + + private MigrateThingDefinitionStrategy underTest; + + @Before + public void setUp() throws Exception { + final ActorSystem actorSystem = ActorSystem.create("test", ConfigFactory.load("test")); + underTest = new MigrateThingDefinitionStrategy(actorSystem); + } + + + @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 thingDefinitionUrl = "https://eclipse-ditto.github.io/ditto-examples/wot/models/dimmable-colored-lamp-1.0.0.tm.jsonld"; + + final MigrateThingDefinition command = MigrateThingDefinition.of( + thingId, + thingDefinitionUrl, + migrationPayload, + null, + true, + DittoHeaders.empty() + ); + + final MigrateThingDefinitionResponse expectedResponse = ETagTestUtils.migrateThingDefinitionResponse(thingId, + getThingJson(thingDefinitionUrl), + getMergedThing(thingDefinitionUrl), + 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); + } + + 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(); + } + +}