diff --git a/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationApi.java b/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationApi.java new file mode 100644 index 000000000..78bc48b2d --- /dev/null +++ b/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationApi.java @@ -0,0 +1,901 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.rest.api.impl.twins; + +import io.mapsmessaging.configuration.ConfigurationProperties; +import io.mapsmessaging.dto.rest.config.protocol.impl.TakProtocolDTO; +import io.mapsmessaging.rest.api.impl.BaseRestApi; +import io.mapsmessaging.rest.responses.StatusResponse; +import io.mapsmessaging.state.config.DroneInfo; +import io.mapsmessaging.state.config.MavlinkTwinConfigDTO; +import io.mapsmessaging.state.config.TwinManagerConfig; +import io.mapsmessaging.state.config.TwinPublishConfigDTO; +import io.mapsmessaging.state.config.n2k.N2KTwinConfig; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.io.IOException; +import java.util.Map; + +import static io.mapsmessaging.rest.api.Constants.URI_PATH; + +@Tag( + name = "Server Twin Configuration", + description = "Configuration endpoints for digital twin integrations. Changes are persisted to TwinManager configuration and do not reload running state services." +) +@Path(URI_PATH + "/server/twin/config") +public class TwinConfigurationApi extends BaseRestApi { + + private static final String RESOURCE = "server/twin/config"; + + @GET + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Get twin configuration", + description = "Returns the current persisted twin manager configuration, including core timing, publishing, TAK, N2K, MAVLink, drone metadata and state adapter sections.", + responses = { + @ApiResponse(responseCode = "200", description = "Twin configuration returned", content = @Content(mediaType = "application/json", schema = @Schema(implementation = TwinManagerConfig.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Server twin configuration error", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response getTwinConfiguration() { + try { + hasAccess(RESOURCE); + return ok(store().getConfig()); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @GET + @Path("/core") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Get core twin manager configuration", + description = "Returns persisted lifecycle timing and root path settings. Runtime twin manager timing is not reloaded by this endpoint.", + responses = { + @ApiResponse(responseCode = "200", description = "Core twin configuration returned", content = @Content(mediaType = "application/json", schema = @Schema(implementation = TwinCoreConfigDTO.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Server twin configuration error", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response getCoreConfig() { + try { + hasAccess(RESOURCE); + return ok(store().getCoreConfig()); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @PUT + @Path("/core") + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Update core twin manager configuration", + description = "Persists core twin lifecycle timing and root path settings. The running StateManagerAgent is not reloaded.", + requestBody = @RequestBody( + description = "Core twin manager timing and lifecycle configuration to persist", + required = true, + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TwinCoreConfigDTO.class)) + ), + responses = { + @ApiResponse(responseCode = "200", description = "Core twin configuration persisted", content = @Content(mediaType = "application/json", schema = @Schema(implementation = TwinCoreConfigDTO.class))), + @ApiResponse(responseCode = "400", description = "Invalid core twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response updateCoreConfig(TwinCoreConfigDTO coreConfig) { + try { + hasAccess(RESOURCE); + store().updateCoreConfig(coreConfig); + removeUriFromCache(uriInfo.getPath()); + return ok(coreConfig); + } catch (TwinConfigurationStore.TwinConfigurationException ex) { + return status(ex); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (IOException ex) { + return internalServerError("Unable to save twin configuration"); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @GET + @Path("/tak") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Get TAK twin configuration", + description = "Returns the persisted TAK publishing configuration used by twin state integration. Runtime TAK connections are not reloaded by this endpoint.", + responses = { + @ApiResponse(responseCode = "200", description = "TAK configuration returned", content = @Content(mediaType = "application/json", schema = @Schema(implementation = TakProtocolDTO.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "TAK configuration is not configured", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Server twin configuration error", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response getTakConfig() { + try { + hasAccess(RESOURCE); + return optionalResponse(store().getTakConfig(), "TAK configuration is not configured"); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @PUT + @Path("/tak") + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Create or update TAK twin configuration", + description = "Persists TAK configuration for the twin manager. Existing runtime TAK observers are not restarted.", + requestBody = @RequestBody( + description = "TAK protocol configuration to persist", + required = true, + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TakProtocolDTO.class)) + ), + responses = { + @ApiResponse(responseCode = "200", description = "TAK configuration persisted", content = @Content(mediaType = "application/json", schema = @Schema(implementation = TakProtocolDTO.class))), + @ApiResponse(responseCode = "400", description = "Invalid TAK configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response putTakConfig(TakProtocolDTO takConfig) { + return mutateOk(takConfig, () -> store().putTakConfig(takConfig)); + } + + @DELETE + @Path("/tak") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Delete TAK twin configuration", + description = "Removes the persisted TAK configuration. Existing runtime TAK observers are not stopped.", + responses = { + @ApiResponse(responseCode = "204", description = "TAK configuration removed"), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "TAK configuration is not configured", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response deleteTakConfig() { + return mutateNoContent(() -> store().deleteTakConfig()); + } + + @GET + @Path("/publish") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Get twin publish configuration", + description = "Returns persisted configuration for publishing twin updates to messaging topics. Runtime publishers are not reloaded by this endpoint.", + responses = { + @ApiResponse(responseCode = "200", description = "Publish configuration returned", content = @Content(mediaType = "application/json", schema = @Schema(implementation = TwinPublishConfigDTO.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "Publish configuration is not configured", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Server twin configuration error", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response getPublishConfig() { + try { + hasAccess(RESOURCE); + return optionalResponse(store().getPublishConfig(), "Publish configuration is not configured"); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @PUT + @Path("/publish") + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Create or update twin publish configuration", + description = "Persists publishing configuration for twin updates. The running TwinPublisherManager is not restarted.", + requestBody = @RequestBody( + description = "Twin publish configuration to persist", + required = true, + content = @Content(mediaType = "application/json", schema = @Schema(implementation = TwinPublishConfigDTO.class)) + ), + responses = { + @ApiResponse(responseCode = "200", description = "Publish configuration persisted", content = @Content(mediaType = "application/json", schema = @Schema(implementation = TwinPublishConfigDTO.class))), + @ApiResponse(responseCode = "400", description = "Invalid publish configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response putPublishConfig(TwinPublishConfigDTO publishConfig) { + return mutateOk(publishConfig, () -> store().putPublishConfig(publishConfig)); + } + + @DELETE + @Path("/publish") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Delete twin publish configuration", + description = "Removes the persisted publish configuration. Existing runtime publishers are not stopped.", + responses = { + @ApiResponse(responseCode = "204", description = "Publish configuration removed"), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "Publish configuration is not configured", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response deletePublishConfig() { + return mutateNoContent(() -> store().deletePublishConfig()); + } + + @GET + @Path("/n2k") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Get N2K twin configuration", + description = "Returns persisted NMEA 2000 twin integration configuration. Runtime N2K subscriptions and AIS projection are not reloaded by this endpoint.", + responses = { + @ApiResponse(responseCode = "200", description = "N2K configuration returned", content = @Content(mediaType = "application/json", schema = @Schema(implementation = N2KTwinConfig.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "N2K configuration is not configured", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Server twin configuration error", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response getN2kConfig() { + try { + hasAccess(RESOURCE); + return optionalResponse(store().getN2kConfig(), "N2K configuration is not configured"); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @PUT + @Path("/n2k") + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Create or update N2K twin configuration", + description = "Persists NMEA 2000 twin integration configuration. The running N2K session and AIS manager are not restarted.", + requestBody = @RequestBody( + description = "N2K twin integration configuration to persist", + required = true, + content = @Content(mediaType = "application/json", schema = @Schema(implementation = N2KTwinConfig.class)) + ), + responses = { + @ApiResponse(responseCode = "200", description = "N2K configuration persisted", content = @Content(mediaType = "application/json", schema = @Schema(implementation = N2KTwinConfig.class))), + @ApiResponse(responseCode = "400", description = "Invalid N2K configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response putN2kConfig(N2KTwinConfig n2kConfig) { + return mutateOk(n2kConfig, () -> store().putN2kConfig(n2kConfig)); + } + + @DELETE + @Path("/n2k") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Disable N2K twin configuration", + description = "Persists N2K as disabled. Existing runtime N2K subscriptions and AIS monitors are not stopped.", + responses = { + @ApiResponse(responseCode = "204", description = "N2K configuration disabled"), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "N2K configuration is not configured", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response deleteN2kConfig() { + return mutateNoContent(() -> store().deleteN2kConfig()); + } + + @GET + @Path("/mavlink") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "List MAVLink twin source configurations", + description = "Returns persisted MAVLink topic sources that are processed into twin state. Runtime MAVLink subscribers are not reloaded by this endpoint.", + responses = { + @ApiResponse(responseCode = "200", description = "MAVLink twin sources returned", content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = MavlinkTwinConfigDTO.class)))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Server twin configuration error", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response listMavlinkSources() { + try { + hasAccess(RESOURCE); + return ok(store().listMavlinkSources().toArray(new MavlinkTwinConfigDTO[0])); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @GET + @Path("/mavlink/{name}") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Get MAVLink twin source configuration", + description = "Returns one persisted MAVLink twin source configuration by name.", + responses = { + @ApiResponse(responseCode = "200", description = "MAVLink twin source returned", content = @Content(mediaType = "application/json", schema = @Schema(implementation = MavlinkTwinConfigDTO.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "MAVLink twin source not found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Server twin configuration error", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response getMavlinkSource(@PathParam("name") String name) { + try { + hasAccess(RESOURCE); + return store().getMavlinkSource(name) + .map(this::ok) + .orElseGet(() -> notFound("Unknown MAVLink twin source: " + name)); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @POST + @Path("/mavlink") + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Create MAVLink twin source configuration", + description = "Adds a persisted MAVLink topic source. The running MavlinkTwinManager is not reloaded.", + requestBody = @RequestBody( + description = "MAVLink twin source configuration to add", + required = true, + content = @Content(mediaType = "application/json", schema = @Schema(implementation = MavlinkTwinConfigDTO.class)) + ), + responses = { + @ApiResponse(responseCode = "201", description = "MAVLink twin source created", content = @Content(mediaType = "application/json", schema = @Schema(implementation = MavlinkTwinConfigDTO.class))), + @ApiResponse(responseCode = "400", description = "Invalid MAVLink twin source configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "409", description = "MAVLink twin source already exists", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response createMavlinkSource(MavlinkTwinConfigDTO mavlinkConfig) { + try { + hasAccess(RESOURCE); + store().createMavlinkSource(mavlinkConfig); + removeUriFromCache(uriInfo.getPath()); + return Response.status(Response.Status.CREATED).entity(mavlinkConfig).build(); + } catch (TwinConfigurationStore.TwinConfigurationException ex) { + return status(ex); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (IOException ex) { + return internalServerError("Unable to save twin configuration"); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @PUT + @Path("/mavlink/{name}") + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Update MAVLink twin source configuration", + description = "Replaces a persisted MAVLink topic source. The path name must match the body name. Runtime subscribers are not reloaded.", + requestBody = @RequestBody( + description = "MAVLink twin source configuration to persist", + required = true, + content = @Content(mediaType = "application/json", schema = @Schema(implementation = MavlinkTwinConfigDTO.class)) + ), + responses = { + @ApiResponse(responseCode = "200", description = "MAVLink twin source updated", content = @Content(mediaType = "application/json", schema = @Schema(implementation = MavlinkTwinConfigDTO.class))), + @ApiResponse(responseCode = "400", description = "Invalid MAVLink twin source configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "MAVLink twin source not found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response updateMavlinkSource(@PathParam("name") String name, MavlinkTwinConfigDTO mavlinkConfig) { + try { + hasAccess(RESOURCE); + store().updateMavlinkSource(name, mavlinkConfig); + removeUriFromCache(uriInfo.getPath()); + return ok(mavlinkConfig); + } catch (TwinConfigurationStore.TwinConfigurationException ex) { + return status(ex); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (IOException ex) { + return internalServerError("Unable to save twin configuration"); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @DELETE + @Path("/mavlink/{name}") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Delete MAVLink twin source configuration", + description = "Removes a persisted MAVLink topic source. Runtime subscribers are not stopped.", + responses = { + @ApiResponse(responseCode = "204", description = "MAVLink twin source deleted"), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "MAVLink twin source not found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response deleteMavlinkSource(@PathParam("name") String name) { + try { + hasAccess(RESOURCE); + store().deleteMavlinkSource(name); + removeUriFromCache(uriInfo.getPath()); + return noContent(); + } catch (TwinConfigurationStore.TwinConfigurationException ex) { + return status(ex); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (IOException ex) { + return internalServerError("Unable to save twin configuration"); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @GET + @Path("/drone-info") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "List known drone configurations", + description = "Returns persisted drone metadata used when MAVLink sources create or update drone twins. Runtime drone metadata registries are not reloaded.", + responses = { + @ApiResponse(responseCode = "200", description = "Drone configurations returned", content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = DroneInfo.class)))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Server twin configuration error", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response listDrones() { + try { + hasAccess(RESOURCE); + return ok(store().listDrones().toArray(new DroneInfo[0])); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @GET + @Path("/drone-info/{name}") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Get known drone configuration", + description = "Returns one persisted drone metadata entry by name.", + responses = { + @ApiResponse(responseCode = "200", description = "Drone configuration returned", content = @Content(mediaType = "application/json", schema = @Schema(implementation = DroneInfo.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "Drone configuration not found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Server twin configuration error", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response getDrone(@PathParam("name") String name) { + try { + hasAccess(RESOURCE); + return store().getDrone(name) + .map(this::ok) + .orElseGet(() -> notFound("Unknown drone configuration: " + name)); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @POST + @Path("/drone-info") + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Create known drone configuration", + description = "Adds persisted drone metadata. Existing runtime drone metadata registries are not reloaded.", + requestBody = @RequestBody( + description = "Drone metadata configuration to add", + required = true, + content = @Content(mediaType = "application/json", schema = @Schema(implementation = DroneInfo.class)) + ), + responses = { + @ApiResponse(responseCode = "201", description = "Drone configuration created", content = @Content(mediaType = "application/json", schema = @Schema(implementation = DroneInfo.class))), + @ApiResponse(responseCode = "400", description = "Invalid drone configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "409", description = "Drone configuration already exists", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response createDrone(DroneInfo droneInfo) { + try { + hasAccess(RESOURCE); + store().createDrone(droneInfo); + removeUriFromCache(uriInfo.getPath()); + return Response.status(Response.Status.CREATED).entity(droneInfo).build(); + } catch (TwinConfigurationStore.TwinConfigurationException ex) { + return status(ex); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (IOException ex) { + return internalServerError("Unable to save twin configuration"); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @PUT + @Path("/drone-info/{name}") + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Update known drone configuration", + description = "Replaces persisted drone metadata. The path name must match the body name. Runtime drone metadata registries are not reloaded.", + requestBody = @RequestBody( + description = "Drone metadata configuration to persist", + required = true, + content = @Content(mediaType = "application/json", schema = @Schema(implementation = DroneInfo.class)) + ), + responses = { + @ApiResponse(responseCode = "200", description = "Drone configuration updated", content = @Content(mediaType = "application/json", schema = @Schema(implementation = DroneInfo.class))), + @ApiResponse(responseCode = "400", description = "Invalid drone configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "Drone configuration not found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response updateDrone(@PathParam("name") String name, DroneInfo droneInfo) { + try { + hasAccess(RESOURCE); + store().updateDrone(name, droneInfo); + removeUriFromCache(uriInfo.getPath()); + return ok(droneInfo); + } catch (TwinConfigurationStore.TwinConfigurationException ex) { + return status(ex); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (IOException ex) { + return internalServerError("Unable to save twin configuration"); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @DELETE + @Path("/drone-info/{name}") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Delete known drone configuration", + description = "Removes persisted drone metadata. Runtime drone metadata registries are not reloaded.", + responses = { + @ApiResponse(responseCode = "204", description = "Drone configuration deleted"), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "Drone configuration not found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response deleteDrone(@PathParam("name") String name) { + try { + hasAccess(RESOURCE); + store().deleteDrone(name); + removeUriFromCache(uriInfo.getPath()); + return noContent(); + } catch (TwinConfigurationStore.TwinConfigurationException ex) { + return status(ex); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (IOException ex) { + return internalServerError("Unable to save twin configuration"); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @GET + @Path("/adapters") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "List state adapter configurations", + description = "Returns persisted state adapter configuration blocks keyed by adapter name. Runtime state adapter instances are not reloaded.", + responses = { + @ApiResponse(responseCode = "200", description = "State adapter configurations returned", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Map.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Server twin configuration error", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response listAdapterConfigs() { + try { + hasAccess(RESOURCE); + return ok(store().listAdapterConfigs()); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @PUT + @Path("/adapters") + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Replace all state adapter configurations", + description = "Replaces the persisted state adapter configuration map. Runtime adapter instances are not reloaded.", + requestBody = @RequestBody( + description = "Map of adapter names to adapter-specific configuration blocks", + required = true, + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Map.class)) + ), + responses = { + @ApiResponse(responseCode = "200", description = "State adapter configuration map persisted", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Map.class))), + @ApiResponse(responseCode = "400", description = "Invalid adapter configuration map", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response replaceAdapterConfigs(Map adapterConfigs) { + return mutateOk(adapterConfigs, () -> store().replaceAdapterConfigs(adapterConfigs)); + } + + @GET + @Path("/adapters/{name}") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Get state adapter configuration", + description = "Returns one persisted state adapter configuration block by adapter name.", + responses = { + @ApiResponse(responseCode = "200", description = "State adapter configuration returned", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ConfigurationProperties.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "State adapter configuration not found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Server twin configuration error", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response getAdapterConfig(@PathParam("name") String name) { + try { + hasAccess(RESOURCE); + return optionalResponse(store().getAdapterConfig(name), "Unknown state adapter configuration: " + name); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @POST + @Path("/adapters/{name}") + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Create state adapter configuration", + description = "Adds a persisted adapter-specific configuration block. Runtime state adapter instances are not reloaded.", + requestBody = @RequestBody( + description = "Adapter-specific configuration block to add", + required = true, + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ConfigurationProperties.class)) + ), + responses = { + @ApiResponse(responseCode = "201", description = "State adapter configuration created", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ConfigurationProperties.class))), + @ApiResponse(responseCode = "400", description = "Invalid adapter configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "409", description = "State adapter configuration already exists", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response createAdapterConfig(@PathParam("name") String name, ConfigurationProperties adapterConfig) { + return mutateCreated(adapterConfig, () -> store().createAdapterConfig(name, adapterConfig)); + } + + @PUT + @Path("/adapters/{name}") + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Update state adapter configuration", + description = "Replaces one persisted adapter-specific configuration block. Runtime state adapter instances are not reloaded.", + requestBody = @RequestBody( + description = "Adapter-specific configuration block to persist", + required = true, + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ConfigurationProperties.class)) + ), + responses = { + @ApiResponse(responseCode = "200", description = "State adapter configuration updated", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ConfigurationProperties.class))), + @ApiResponse(responseCode = "400", description = "Invalid adapter configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "State adapter configuration not found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response updateAdapterConfig(@PathParam("name") String name, ConfigurationProperties adapterConfig) { + return mutateOk(adapterConfig, () -> store().updateAdapterConfig(name, adapterConfig)); + } + + @DELETE + @Path("/adapters/{name}") + @Produces({MediaType.APPLICATION_JSON}) + @Operation( + summary = "Delete state adapter configuration", + description = "Removes one persisted state adapter configuration block. Runtime state adapter instances are not stopped.", + responses = { + @ApiResponse(responseCode = "204", description = "State adapter configuration deleted"), + @ApiResponse(responseCode = "401", description = "Invalid credentials or unauthorized access", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "403", description = "User is not authorised to access the resource", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "404", description = "State adapter configuration not found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))), + @ApiResponse(responseCode = "500", description = "Unable to save twin configuration", content = @Content(mediaType = "application/json", schema = @Schema(implementation = StatusResponse.class))) + } + ) + public Response deleteAdapterConfig(@PathParam("name") String name) { + return mutateNoContent(() -> store().deleteAdapterConfig(name)); + } + + TwinConfigurationStore store() { + TwinManagerConfig config = TwinManagerConfig.getInstance(); + if (config == null) { + throw new IllegalStateException("TwinManager configuration is not available"); + } + return new TwinConfigurationStore(config); + } + + private Response status(TwinConfigurationStore.TwinConfigurationException exception) { + return Response.status(exception.getStatusCode()) + .entity(new StatusResponse(exception.getMessage())) + .type(MediaType.APPLICATION_JSON) + .build(); + } + + private Response optionalResponse(java.util.Optional optional, String message) { + return optional + .map(this::ok) + .orElseGet(() -> notFound(message)); + } + + private Response mutateCreated(Object entity, SaveAction action) { + try { + hasAccess(RESOURCE); + action.run(); + removeUriFromCache(uriInfo.getPath()); + return Response.status(Response.Status.CREATED).entity(entity).build(); + } catch (TwinConfigurationStore.TwinConfigurationException ex) { + return status(ex); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (IOException ex) { + return internalServerError("Unable to save twin configuration"); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + private Response mutateOk(Object entity, SaveAction action) { + try { + hasAccess(RESOURCE); + action.run(); + removeUriFromCache(uriInfo.getPath()); + return ok(entity); + } catch (TwinConfigurationStore.TwinConfigurationException ex) { + return status(ex); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (IOException ex) { + return internalServerError("Unable to save twin configuration"); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + private Response mutateNoContent(SaveAction action) { + try { + hasAccess(RESOURCE); + action.run(); + removeUriFromCache(uriInfo.getPath()); + return noContent(); + } catch (TwinConfigurationStore.TwinConfigurationException ex) { + return status(ex); + } catch (WebApplicationException ex) { + return mapAuthOrRethrow(ex); + } catch (IOException ex) { + return internalServerError("Unable to save twin configuration"); + } catch (Exception ex) { + return internalServerError("Server twin configuration error"); + } + } + + @FunctionalInterface + private interface SaveAction { + + void run() throws IOException; + } + + private Response mapAuthOrRethrow(WebApplicationException exception) { + Response response = exception.getResponse(); + int status = response == null ? 500 : response.getStatus(); + if (status == 401) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(new StatusResponse("Unauthorized")) + .type(MediaType.APPLICATION_JSON) + .build(); + } + if (status == 403) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new StatusResponse("Access denied")) + .type(MediaType.APPLICATION_JSON) + .build(); + } + throw exception; + } +} diff --git a/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationStore.java b/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationStore.java new file mode 100644 index 000000000..2589af2b3 --- /dev/null +++ b/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationStore.java @@ -0,0 +1,352 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.rest.api.impl.twins; + +import io.mapsmessaging.configuration.ConfigurationProperties; +import io.mapsmessaging.dto.rest.config.protocol.impl.TakProtocolDTO; +import io.mapsmessaging.state.config.DroneInfo; +import io.mapsmessaging.state.config.MavlinkTwinConfigDTO; +import io.mapsmessaging.state.config.TwinManagerConfig; +import io.mapsmessaging.state.config.TwinPublishConfigDTO; +import io.mapsmessaging.state.config.n2k.N2KTwinConfig; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +class TwinConfigurationStore { + + private final TwinManagerConfig config; + + TwinConfigurationStore(TwinManagerConfig config) { + this.config = config; + } + + TwinManagerConfig getConfig() { + return config; + } + + TwinCoreConfigDTO getCoreConfig() { + TwinCoreConfigDTO coreConfig = new TwinCoreConfigDTO(); + coreConfig.setHeartbeatTimeoutMillis(config.getHeartbeatTimeoutMillis()); + coreConfig.setStaleTimeoutMillis(config.getStaleTimeoutMillis()); + coreConfig.setRetentionTimeoutMillis(config.getRetentionTimeoutMillis()); + coreConfig.setRemoveExpiredTwins(config.isRemoveExpiredTwins()); + coreConfig.setDefaultRootPath(config.getDefaultRootPath()); + return coreConfig; + } + + void updateCoreConfig(TwinCoreConfigDTO coreConfig) throws IOException { + validateCoreConfig(coreConfig); + config.setHeartbeatTimeoutMillis(coreConfig.getHeartbeatTimeoutMillis()); + config.setStaleTimeoutMillis(coreConfig.getStaleTimeoutMillis()); + config.setRetentionTimeoutMillis(coreConfig.getRetentionTimeoutMillis()); + config.setRemoveExpiredTwins(coreConfig.isRemoveExpiredTwins()); + config.setDefaultRootPath(coreConfig.getDefaultRootPath()); + config.save(); + } + + Optional getTakConfig() { + return Optional.ofNullable(config.getTak()); + } + + void putTakConfig(TakProtocolDTO takConfig) throws IOException { + validateTakConfig(takConfig); + config.setTak(takConfig); + config.save(); + } + + void deleteTakConfig() throws IOException { + if (config.getTak() == null) { + throw new TwinConfigurationException("TAK configuration is not configured", 404); + } + config.setTak(null); + config.save(); + } + + Optional getPublishConfig() { + return Optional.ofNullable(config.getPublish()); + } + + void putPublishConfig(TwinPublishConfigDTO publishConfig) throws IOException { + validatePublishConfig(publishConfig); + config.setPublish(publishConfig); + config.save(); + } + + void deletePublishConfig() throws IOException { + if (config.getPublish() == null) { + throw new TwinConfigurationException("Publish configuration is not configured", 404); + } + config.setPublish(null); + config.save(); + } + + Optional getN2kConfig() { + return Optional.ofNullable(config.getN2KTwinConfig()); + } + + void putN2kConfig(N2KTwinConfig n2kConfig) throws IOException { + validateN2kConfig(n2kConfig); + config.setN2KTwinConfig(n2kConfig); + config.save(); + } + + void deleteN2kConfig() throws IOException { + if (config.getN2KTwinConfig() == null) { + throw new TwinConfigurationException("N2K configuration is not configured", 404); + } + N2KTwinConfig disabledConfig = new N2KTwinConfig(); + disabledConfig.setEnable(false); + disabledConfig.setPublishMavlinkDrones(false); + config.setN2KTwinConfig(disabledConfig); + config.save(); + } + + List listMavlinkSources() { + return config.getMavlink(); + } + + Optional getMavlinkSource(String name) { + return config.getMavlink().stream() + .filter(entry -> name.equals(entry.getName())) + .findFirst(); + } + + void createMavlinkSource(MavlinkTwinConfigDTO mavlinkConfig) throws IOException { + validateMavlinkSource(mavlinkConfig); + if (getMavlinkSource(mavlinkConfig.getName()).isPresent()) { + throw new TwinConfigurationException("MAVLink twin source already exists: " + mavlinkConfig.getName(), 409); + } + config.getMavlink().add(mavlinkConfig); + config.save(); + } + + void updateMavlinkSource(String name, MavlinkTwinConfigDTO mavlinkConfig) throws IOException { + validateName(name, "name"); + validateMavlinkSource(mavlinkConfig); + if (!name.equals(mavlinkConfig.getName())) { + throw new TwinConfigurationException("MAVLink twin source name cannot be changed", 400); + } + + List mavlinkSources = config.getMavlink(); + for (int index = 0; index < mavlinkSources.size(); index++) { + if (name.equals(mavlinkSources.get(index).getName())) { + mavlinkSources.set(index, mavlinkConfig); + config.save(); + return; + } + } + throw new TwinConfigurationException("Unknown MAVLink twin source: " + name, 404); + } + + void deleteMavlinkSource(String name) throws IOException { + validateName(name, "name"); + if (!config.getMavlink().removeIf(entry -> name.equals(entry.getName()))) { + throw new TwinConfigurationException("Unknown MAVLink twin source: " + name, 404); + } + config.save(); + } + + List listDrones() { + return config.getDroneInfo(); + } + + Optional getDrone(String name) { + return config.getDroneInfo().stream() + .filter(entry -> name.equals(entry.getName())) + .findFirst(); + } + + void createDrone(DroneInfo droneInfo) throws IOException { + validateDrone(droneInfo); + if (getDrone(droneInfo.getName()).isPresent()) { + throw new TwinConfigurationException("Drone configuration already exists: " + droneInfo.getName(), 409); + } + config.getDroneInfo().add(droneInfo); + config.save(); + } + + void updateDrone(String name, DroneInfo droneInfo) throws IOException { + validateName(name, "name"); + validateDrone(droneInfo); + if (!name.equals(droneInfo.getName())) { + throw new TwinConfigurationException("Drone configuration name cannot be changed", 400); + } + + List droneInfos = config.getDroneInfo(); + for (int index = 0; index < droneInfos.size(); index++) { + if (name.equals(droneInfos.get(index).getName())) { + droneInfos.set(index, droneInfo); + config.save(); + return; + } + } + throw new TwinConfigurationException("Unknown drone configuration: " + name, 404); + } + + void deleteDrone(String name) throws IOException { + validateName(name, "name"); + if (!config.getDroneInfo().removeIf(entry -> name.equals(entry.getName()))) { + throw new TwinConfigurationException("Unknown drone configuration: " + name, 404); + } + config.save(); + } + + Map listAdapterConfigs() { + return config.getAdapterConfig(); + } + + Optional getAdapterConfig(String name) { + return Optional.ofNullable(config.getAdapterConfig().get(name)); + } + + void createAdapterConfig(String name, ConfigurationProperties adapterConfig) throws IOException { + validateName(name, "name"); + validateAdapterConfig(adapterConfig); + if (config.getAdapterConfig().containsKey(name)) { + throw new TwinConfigurationException("State adapter configuration already exists: " + name, 409); + } + config.getAdapterConfig().put(name, adapterConfig); + config.save(); + } + + void updateAdapterConfig(String name, ConfigurationProperties adapterConfig) throws IOException { + validateName(name, "name"); + validateAdapterConfig(adapterConfig); + if (!config.getAdapterConfig().containsKey(name)) { + throw new TwinConfigurationException("Unknown state adapter configuration: " + name, 404); + } + config.getAdapterConfig().put(name, adapterConfig); + config.save(); + } + + void deleteAdapterConfig(String name) throws IOException { + validateName(name, "name"); + if (config.getAdapterConfig().remove(name) == null) { + throw new TwinConfigurationException("Unknown state adapter configuration: " + name, 404); + } + config.save(); + } + + void replaceAdapterConfigs(Map adapterConfigs) throws IOException { + if (adapterConfigs == null) { + throw new TwinConfigurationException("State adapter configuration map is required", 400); + } + config.setAdapterConfig(new LinkedHashMap<>(adapterConfigs)); + config.save(); + } + + private void validateCoreConfig(TwinCoreConfigDTO coreConfig) { + if (coreConfig == null) { + throw new TwinConfigurationException("Core twin configuration is required", 400); + } + if (coreConfig.getHeartbeatTimeoutMillis() <= 0) { + throw new TwinConfigurationException("heartbeatTimeoutMillis must be greater than 0", 400); + } + if (coreConfig.getStaleTimeoutMillis() <= 0) { + throw new TwinConfigurationException("staleTimeoutMillis must be greater than 0", 400); + } + if (coreConfig.getRetentionTimeoutMillis() < 0) { + throw new TwinConfigurationException("retentionTimeoutMillis must be greater than or equal to 0", 400); + } + if (coreConfig.getDefaultRootPath() == null || coreConfig.getDefaultRootPath().isBlank()) { + throw new TwinConfigurationException("defaultRootPath is required", 400); + } + } + + private void validateTakConfig(TakProtocolDTO takConfig) { + if (takConfig == null) { + throw new TwinConfigurationException("TAK configuration is required", 400); + } + if (takConfig.getHostname() == null || takConfig.getHostname().isBlank()) { + throw new TwinConfigurationException("hostname is required", 400); + } + if (takConfig.getPort() <= 0 || takConfig.getPort() > 65535) { + throw new TwinConfigurationException("port must be between 1 and 65535", 400); + } + } + + private void validatePublishConfig(TwinPublishConfigDTO publishConfig) { + if (publishConfig == null) { + throw new TwinConfigurationException("Publish configuration is required", 400); + } + if (publishConfig.isEnabled() && (publishConfig.getTopicTemplate() == null || publishConfig.getTopicTemplate().isBlank())) { + throw new TwinConfigurationException("topicTemplate is required when publishing is enabled", 400); + } + } + + private void validateN2kConfig(N2KTwinConfig n2kConfig) { + if (n2kConfig == null) { + throw new TwinConfigurationException("N2K configuration is required", 400); + } + if (n2kConfig.isEnable() && (n2kConfig.getTopic() == null || n2kConfig.getTopic().isBlank())) { + throw new TwinConfigurationException("topic is required when N2K is enabled", 400); + } + } + + private void validateAdapterConfig(ConfigurationProperties adapterConfig) { + if (adapterConfig == null) { + throw new TwinConfigurationException("State adapter configuration is required", 400); + } + } + + private void validateMavlinkSource(MavlinkTwinConfigDTO mavlinkConfig) { + if (mavlinkConfig == null) { + throw new TwinConfigurationException("MAVLink twin source configuration is required", 400); + } + validateName(mavlinkConfig.getName(), "name"); + if (mavlinkConfig.getTopic() == null || mavlinkConfig.getTopic().isBlank()) { + throw new TwinConfigurationException("topic is required", 400); + } + } + + private void validateDrone(DroneInfo droneInfo) { + if (droneInfo == null) { + throw new TwinConfigurationException("Drone configuration is required", 400); + } + validateName(droneInfo.getName(), "name"); + if (droneInfo.getUuid() == null) { + throw new TwinConfigurationException("uuid is required", 400); + } + } + + private void validateName(String name, String fieldName) { + if (name == null || name.isBlank()) { + throw new TwinConfigurationException(fieldName + " is required", 400); + } + } + + static class TwinConfigurationException extends RuntimeException { + + private final int statusCode; + + TwinConfigurationException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + int getStatusCode() { + return statusCode; + } + } +} diff --git a/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinCoreConfigDTO.java b/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinCoreConfigDTO.java new file mode 100644 index 000000000..e295ed93d --- /dev/null +++ b/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinCoreConfigDTO.java @@ -0,0 +1,116 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.rest.api.impl.twins; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema( + name = "TwinCoreConfigDTO", + description = "Core twin manager timing and lifecycle configuration. Values are persisted to TwinManager configuration and are not applied to the running TwinManager until reload or restart." +) +public class TwinCoreConfigDTO { + + @Schema( + description = "Time in milliseconds after which a twin is considered disconnected if no updates are received.", + example = "5000", + defaultValue = "5000", + minimum = "1", + maximum = "600000", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private long heartbeatTimeoutMillis; + + @Schema( + description = "Time in milliseconds after which a twin is considered stale if no updates are received.", + example = "10000", + defaultValue = "10000", + minimum = "1", + maximum = "600000", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private long staleTimeoutMillis; + + @Schema( + description = "Time in milliseconds after which an expired twin may be removed from memory.", + example = "120000", + defaultValue = "120000", + minimum = "0", + maximum = "86400000", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private long retentionTimeoutMillis; + + @Schema( + description = "If true, twins that exceed the retention timeout are removed from memory.", + example = "true", + defaultValue = "true", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private boolean removeExpiredTwins; + + @Schema( + description = "Default root path used when constructing twin hierarchical paths.", + example = "/", + defaultValue = "/", + minLength = 1, + requiredMode = Schema.RequiredMode.REQUIRED + ) + private String defaultRootPath; + + public long getHeartbeatTimeoutMillis() { + return heartbeatTimeoutMillis; + } + + public void setHeartbeatTimeoutMillis(long heartbeatTimeoutMillis) { + this.heartbeatTimeoutMillis = heartbeatTimeoutMillis; + } + + public long getStaleTimeoutMillis() { + return staleTimeoutMillis; + } + + public void setStaleTimeoutMillis(long staleTimeoutMillis) { + this.staleTimeoutMillis = staleTimeoutMillis; + } + + public long getRetentionTimeoutMillis() { + return retentionTimeoutMillis; + } + + public void setRetentionTimeoutMillis(long retentionTimeoutMillis) { + this.retentionTimeoutMillis = retentionTimeoutMillis; + } + + public boolean isRemoveExpiredTwins() { + return removeExpiredTwins; + } + + public void setRemoveExpiredTwins(boolean removeExpiredTwins) { + this.removeExpiredTwins = removeExpiredTwins; + } + + public String getDefaultRootPath() { + return defaultRootPath; + } + + public void setDefaultRootPath(String defaultRootPath) { + this.defaultRootPath = defaultRootPath; + } +} diff --git a/src/main/java/io/mapsmessaging/state/config/TwinManagerConfig.java b/src/main/java/io/mapsmessaging/state/config/TwinManagerConfig.java index 0a70758dd..9a109ca26 100644 --- a/src/main/java/io/mapsmessaging/state/config/TwinManagerConfig.java +++ b/src/main/java/io/mapsmessaging/state/config/TwinManagerConfig.java @@ -152,21 +152,22 @@ public ConfigurationProperties toConfigurationProperties() { props.put(STATE_ADAPTERS_CONFIG_KEY, new ConfigurationProperties(new LinkedHashMap<>(this.adapterConfig))); } - ConfigurationProperties n2kProps = new ConfigurationProperties(); - n2kProps.put("enabled", this.n2KTwinConfig.isEnable()); - n2kProps.put("topic", this.n2KTwinConfig.getTopic()); - n2kProps.put("name", this.n2KTwinConfig.getName()); - n2kProps.put("vehicleClass", this.n2KTwinConfig.getVehicleClass()); - n2kProps.put("publishMavlinkDrones", this.n2KTwinConfig.isPublishMavlinkDrones()); - - if (n2KTwinConfig.getAis() instanceof Config aisConfiguration) { - n2kProps.put("ais", aisConfiguration.toConfigurationProperties()); - } else if (n2KTwinConfig.getAis() != null) { - n2kProps.put("ais", n2KTwinConfig.getAis()); + if (shouldWriteN2kConfiguration()) { + ConfigurationProperties n2kProps = new ConfigurationProperties(); + n2kProps.put("enabled", this.n2KTwinConfig.isEnable()); + n2kProps.put("topic", this.n2KTwinConfig.getTopic()); + n2kProps.put("name", this.n2KTwinConfig.getName()); + n2kProps.put("vehicleClass", this.n2KTwinConfig.getVehicleClass()); + n2kProps.put("publishMavlinkDrones", this.n2KTwinConfig.isPublishMavlinkDrones()); + + if (n2KTwinConfig.getAis() instanceof Config aisConfiguration) { + n2kProps.put("ais", aisConfiguration.toConfigurationProperties()); + } else if (n2KTwinConfig.getAis() != null) { + n2kProps.put("ais", n2KTwinConfig.getAis()); + } + props.put("n2k", n2kProps); } - props.put("n2k", n2kProps); - return props; } @@ -241,6 +242,11 @@ public boolean update(BaseConfigDTO config) { hasChanged = true; } + if (this.droneInfo != newConfig.getDroneInfo()) { + this.droneInfo = newConfig.getDroneInfo(); + hasChanged = true; + } + if (newConfig.getAdapterConfig() != null && !this.adapterConfig.equals(newConfig.getAdapterConfig())) { this.adapterConfig = new LinkedHashMap<>(newConfig.getAdapterConfig()); hasChanged = true; @@ -644,4 +650,16 @@ private List toKnownSourceConfigurationProperties(List< return values; } -} \ No newline at end of file + private boolean shouldWriteN2kConfiguration() { + if (this.n2KTwinConfig == null) { + return false; + } + if (this.n2KTwinConfig.isEnable() || this.n2KTwinConfig.isPublishMavlinkDrones()) { + return true; + } + if (this.n2KTwinConfig.getName() != null || this.n2KTwinConfig.getVehicleClass() != null) { + return true; + } + return this.n2KTwinConfig.getTopic() != null && !"/canbus0/n2k/json/#".equals(this.n2KTwinConfig.getTopic()); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationApiTest.java new file mode 100644 index 000000000..7fd7dfbc6 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationApiTest.java @@ -0,0 +1,478 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.rest.api.impl.twins; + +import io.mapsmessaging.rest.ApiTestBase; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.UUID; + +import static io.mapsmessaging.rest.api.Constants.URI_PATH; +import static org.hamcrest.Matchers.*; + +class TwinConfigurationApiTest extends ApiTestBase { + + private static final String BASE_PATH = URI_PATH + "/server/twin/config"; + + @Test + void getConfigurationAndCore_returns200() { + givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("heartbeatTimeoutMillis", greaterThan(0)) + .body("staleTimeoutMillis", greaterThan(0)) + .body("defaultRootPath", not(isEmptyOrNullString())); + + givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/core") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("heartbeatTimeoutMillis", greaterThan(0)) + .body("staleTimeoutMillis", greaterThan(0)) + .body("defaultRootPath", not(isEmptyOrNullString())); + } + + @Test + void core_putPersistsAndRejectsInvalidPayload() { + Response originalCore = getSection("/core"); + String updatedCore = """ + { + "heartbeatTimeoutMillis": 7000, + "staleTimeoutMillis": 14000, + "retentionTimeoutMillis": 28000, + "removeExpiredTwins": false, + "defaultRootPath": "/it" + } + """; + + try { + givenAuthenticated() + .contentType(ContentType.JSON) + .body(updatedCore) + .when() + .put(BASE_PATH + "/core") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("heartbeatTimeoutMillis", equalTo(7000)) + .body("staleTimeoutMillis", equalTo(14000)) + .body("retentionTimeoutMillis", equalTo(28000)) + .body("removeExpiredTwins", equalTo(false)) + .body("defaultRootPath", equalTo("/it")); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body(""" + { + "heartbeatTimeoutMillis": 0, + "staleTimeoutMillis": 14000, + "retentionTimeoutMillis": 28000, + "removeExpiredTwins": false, + "defaultRootPath": "/it" + } + """) + .when() + .put(BASE_PATH + "/core") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } finally { + putSection("/core", originalCore.asString()); + } + } + + @Test + void singletonSections_putGetDelete_behaveAsCrud() { + Response originalTak = getSectionNoValidation("/tak"); + Response originalPublish = getSectionNoValidation("/publish"); + Response originalN2k = getSectionNoValidation("/n2k"); + + try { + putAndReadTak(); + putAndReadPublish(); + putAndReadN2k(); + } finally { + restoreOptionalSection("/tak", originalTak); + restoreOptionalSection("/publish", originalPublish); + restoreOptionalSection("/n2k", originalN2k); + } + } + + @Test + void mavlink_lifecycle_exposesHttpCrudAndValidation() { + String name = uniqueName("mavlink"); + String body = """ + { + "name": "%s", + "topic": "/it/mavlink/%s/#", + "dialectName": "common" + } + """.formatted(name, name); + + try { + givenAuthenticated() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(BASE_PATH + "/mavlink") + .then() + .statusCode(201) + .contentType(ContentType.JSON) + .body("name", equalTo(name)) + .body("topic", equalTo("/it/mavlink/" + name + "/#")); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(BASE_PATH + "/mavlink") + .then() + .statusCode(409) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + + givenAuthenticated() + .when() + .get(BASE_PATH + "/mavlink/" + name) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("name", equalTo(name)); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body(body.replace("/#", "/updated/#")) + .when() + .put(BASE_PATH + "/mavlink/" + name) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("topic", equalTo("/it/mavlink/" + name + "/updated/#")); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body(body.replace(name, name + "_other")) + .when() + .put(BASE_PATH + "/mavlink/" + name) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } finally { + deleteIfPresent("/mavlink/" + name); + } + + givenAuthenticated() + .when() + .get(BASE_PATH + "/mavlink/" + name) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } + + @Test + void droneInfo_lifecycle_exposesHttpCrudAndValidation() { + String name = uniqueName("drone"); + String body = """ + { + "name": "%s", + "uuid": "%s", + "description": { + "model": "integration-test" + } + } + """.formatted(name, UUID.randomUUID()); + + try { + givenAuthenticated() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(BASE_PATH + "/drone-info") + .then() + .statusCode(201) + .contentType(ContentType.JSON) + .body("name", equalTo(name)); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(BASE_PATH + "/drone-info") + .then() + .statusCode(409) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + + givenAuthenticated() + .when() + .get(BASE_PATH + "/drone-info/" + name) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("name", equalTo(name)); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body(body.replace("integration-test", "updated")) + .when() + .put(BASE_PATH + "/drone-info/" + name) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("description.model", equalTo("updated")); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body(body.replace(name, name + "_other")) + .when() + .put(BASE_PATH + "/drone-info/" + name) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } finally { + deleteIfPresent("/drone-info/" + name); + } + + givenAuthenticated() + .when() + .get(BASE_PATH + "/drone-info/" + name) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } + + @Test + void adapter_lifecycle_exposesHttpCrudAndValidation() { + String name = uniqueName("adapter"); + String body = """ + { + "enabled": true, + "topic": "/it/adapter" + } + """; + + try { + givenAuthenticated() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(BASE_PATH + "/adapters/" + name) + .then() + .statusCode(201) + .contentType(ContentType.JSON) + .body("enabled", equalTo(true)) + .body("topic", equalTo("/it/adapter")); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(BASE_PATH + "/adapters/" + name) + .then() + .statusCode(409) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + + givenAuthenticated() + .when() + .get(BASE_PATH + "/adapters/" + name) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("topic", equalTo("/it/adapter")); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body(body.replace("/it/adapter", "/it/adapter/updated")) + .when() + .put(BASE_PATH + "/adapters/" + name) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("topic", equalTo("/it/adapter/updated")); + } finally { + deleteIfPresent("/adapters/" + name); + } + + givenAuthenticated() + .when() + .get(BASE_PATH + "/adapters/" + name) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } + + private void putAndReadTak() { + String body = """ + { + "type": "tak", + "hostname": "127.0.0.1", + "port": 8088, + "sharedConnection": true, + "topic": "tak/events" + } + """; + + putSection("/tak", body) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("hostname", equalTo("127.0.0.1")) + .body("port", equalTo(8088)) + .body("sharedConnection", equalTo(true)); + + getSection("/tak") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("hostname", equalTo("127.0.0.1")); + + deleteSection("/tak"); + } + + private void putAndReadPublish() { + String body = """ + { + "enabled": true, + "topicTemplate": "/it/twins/{twinId}" + } + """; + + putSection("/publish", body) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("enabled", equalTo(true)) + .body("topicTemplate", equalTo("/it/twins/{twinId}")); + + getSection("/publish") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("topicTemplate", equalTo("/it/twins/{twinId}")); + + deleteSection("/publish"); + } + + private void putAndReadN2k() { + String body = """ + { + "enable": true, + "publishMavlinkDrones": false, + "topic": "/it/n2k/json/#", + "name": "it-n2k", + "vehicleClass": "USV" + } + """; + + putSection("/n2k", body) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("enable", equalTo(true)) + .body("topic", equalTo("/it/n2k/json/#")) + .body("name", equalTo("it-n2k")); + + getSection("/n2k") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("topic", equalTo("/it/n2k/json/#")); + + deleteSection("/n2k"); + } + + private Response getSection(String path) { + return givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + path) + .then() + .extract() + .response(); + } + + private Response getSectionNoValidation(String path) { + return givenAuthenticatedNoValidation() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + path) + .then() + .extract() + .response(); + } + + private Response putSection(String path, String body) { + return givenAuthenticated() + .contentType(ContentType.JSON) + .body(body) + .when() + .put(BASE_PATH + path) + .then() + .extract() + .response(); + } + + private void deleteSection(String path) { + givenAuthenticated() + .when() + .delete(BASE_PATH + path) + .then() + .statusCode(204); + } + + private void deleteIfPresent(String path) { + givenAuthenticatedNoValidation() + .when() + .delete(BASE_PATH + path) + .then() + .statusCode(anyOf(is(204), is(404))); + } + + private void restoreOptionalSection(String path, Response originalResponse) { + if (originalResponse.statusCode() == 200) { + putSection(path, originalResponse.asString()); + return; + } + if (originalResponse.statusCode() == 404) { + deleteIfPresent(path); + } + } + + private String uniqueName(String type) { + return "it_" + type + "_" + Instant.now().toEpochMilli(); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationStoreTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationStoreTest.java new file mode 100644 index 000000000..5874b5724 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationStoreTest.java @@ -0,0 +1,302 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.rest.api.impl.twins; + +import io.mapsmessaging.configuration.ConfigurationProperties; +import io.mapsmessaging.dto.rest.config.protocol.impl.TakProtocolDTO; +import io.mapsmessaging.state.config.DroneInfo; +import io.mapsmessaging.state.config.MavlinkTwinConfigDTO; +import io.mapsmessaging.state.config.TwinManagerConfig; +import io.mapsmessaging.state.config.TwinPublishConfigDTO; +import io.mapsmessaging.state.config.n2k.N2KTwinConfig; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TwinConfigurationStoreTest { + + @Test + void core_update_persistsScalarConfiguration() throws IOException { + SavingTwinManagerConfig config = newConfig(); + TwinConfigurationStore store = new TwinConfigurationStore(config); + TwinCoreConfigDTO coreConfig = new TwinCoreConfigDTO(); + coreConfig.setHeartbeatTimeoutMillis(7500); + coreConfig.setStaleTimeoutMillis(15000); + coreConfig.setRetentionTimeoutMillis(240000); + coreConfig.setRemoveExpiredTwins(false); + coreConfig.setDefaultRootPath("/drones"); + + store.updateCoreConfig(coreConfig); + + assertEquals(7500, config.getHeartbeatTimeoutMillis()); + assertEquals(15000, config.getStaleTimeoutMillis()); + assertEquals(240000, config.getRetentionTimeoutMillis()); + assertFalse(config.isRemoveExpiredTwins()); + assertEquals("/drones", config.getDefaultRootPath()); + assertEquals(1, config.getSaveCount()); + } + + @Test + void tak_putDelete_persistsEachMutation() throws IOException { + SavingTwinManagerConfig config = newConfig(); + TwinConfigurationStore store = new TwinConfigurationStore(config); + TakProtocolDTO created = takConfig("tak.example.net", 8088); + TakProtocolDTO updated = takConfig("tak2.example.net", 8089); + + config.setTak(null); + store.putTakConfig(created); + assertSame(created, store.getTakConfig().orElseThrow()); + + store.putTakConfig(updated); + assertSame(updated, store.getTakConfig().orElseThrow()); + + store.deleteTakConfig(); + + assertTrue(store.getTakConfig().isEmpty()); + assertEquals(3, config.getSaveCount()); + } + + @Test + void publish_putDelete_persistsEachMutation() throws IOException { + SavingTwinManagerConfig config = newConfig(); + TwinConfigurationStore store = new TwinConfigurationStore(config); + TwinPublishConfigDTO created = publishConfig("/state/twins/{twinId}"); + TwinPublishConfigDTO updated = publishConfig("/state/drones/{twinId}"); + + config.setPublish(null); + store.putPublishConfig(created); + assertSame(created, store.getPublishConfig().orElseThrow()); + + store.putPublishConfig(updated); + assertSame(updated, store.getPublishConfig().orElseThrow()); + + store.deletePublishConfig(); + + assertTrue(store.getPublishConfig().isEmpty()); + assertEquals(3, config.getSaveCount()); + } + + @Test + void n2k_updateDelete_persistsSpecificConfiguration() throws IOException { + SavingTwinManagerConfig config = newConfig(); + TwinConfigurationStore store = new TwinConfigurationStore(config); + N2KTwinConfig n2kConfig = new N2KTwinConfig(); + n2kConfig.setTopic("/canbus1/n2k/json/#"); + n2kConfig.setName("canbus1"); + n2kConfig.setVehicleClass("USV"); + + store.putN2kConfig(n2kConfig); + assertSame(n2kConfig, store.getN2kConfig().orElseThrow()); + + store.deleteN2kConfig(); + + assertFalse(store.getN2kConfig().orElseThrow().isEnable()); + assertFalse(store.getN2kConfig().orElseThrow().isPublishMavlinkDrones()); + assertEquals(2, config.getSaveCount()); + } + + @Test + void mavlink_createUpdateDelete_persistsEachMutation() throws IOException { + SavingTwinManagerConfig config = newConfig(); + TwinConfigurationStore store = new TwinConfigurationStore(config); + MavlinkTwinConfigDTO created = mavlinkSource("primary", "/mavlink/>"); + MavlinkTwinConfigDTO updated = mavlinkSource("primary", "/mavlink/state/>"); + + store.createMavlinkSource(created); + assertSame(created, store.getMavlinkSource("primary").orElseThrow()); + + store.updateMavlinkSource("primary", updated); + assertSame(updated, store.getMavlinkSource("primary").orElseThrow()); + + store.deleteMavlinkSource("primary"); + + assertTrue(store.listMavlinkSources().isEmpty()); + assertEquals(3, config.getSaveCount()); + } + + @Test + void mavlink_createDuplicate_returnsConflict() throws IOException { + TwinConfigurationStore store = new TwinConfigurationStore(newConfig()); + + store.createMavlinkSource(mavlinkSource("primary", "/mavlink/>")); + TwinConfigurationStore.TwinConfigurationException exception = assertThrows( + TwinConfigurationStore.TwinConfigurationException.class, + () -> store.createMavlinkSource(mavlinkSource("primary", "/other/>")) + ); + + assertEquals(409, exception.getStatusCode()); + } + + @Test + void mavlink_createWithoutTopic_returnsBadRequest() { + TwinConfigurationStore store = new TwinConfigurationStore(newConfig()); + + TwinConfigurationStore.TwinConfigurationException exception = assertThrows( + TwinConfigurationStore.TwinConfigurationException.class, + () -> store.createMavlinkSource(mavlinkSource("primary", " ")) + ); + + assertEquals(400, exception.getStatusCode()); + } + + @Test + void drone_createUpdateDelete_persistsEachMutation() throws IOException { + SavingTwinManagerConfig config = newConfig(); + TwinConfigurationStore store = new TwinConfigurationStore(config); + DroneInfo created = drone("alpha"); + DroneInfo updated = drone("alpha"); + updated.setUuid(UUID.randomUUID()); + + store.createDrone(created); + assertSame(created, store.getDrone("alpha").orElseThrow()); + + store.updateDrone("alpha", updated); + assertSame(updated, store.getDrone("alpha").orElseThrow()); + + store.deleteDrone("alpha"); + + assertTrue(store.listDrones().isEmpty()); + assertEquals(3, config.getSaveCount()); + } + + @Test + void drone_updateChangingName_returnsBadRequest() { + TwinConfigurationStore store = new TwinConfigurationStore(newConfig()); + + TwinConfigurationStore.TwinConfigurationException exception = assertThrows( + TwinConfigurationStore.TwinConfigurationException.class, + () -> store.updateDrone("alpha", drone("bravo")) + ); + + assertEquals(400, exception.getStatusCode()); + } + + @Test + void drone_deleteMissing_returnsNotFound() { + TwinConfigurationStore store = new TwinConfigurationStore(newConfig()); + + TwinConfigurationStore.TwinConfigurationException exception = assertThrows( + TwinConfigurationStore.TwinConfigurationException.class, + () -> store.deleteDrone("missing") + ); + + assertEquals(404, exception.getStatusCode()); + } + + @Test + void adapter_createUpdateDelete_persistsEachMutation() throws IOException { + SavingTwinManagerConfig config = newConfig(); + TwinConfigurationStore store = new TwinConfigurationStore(config); + ConfigurationProperties created = adapterConfig("/source/one"); + ConfigurationProperties updated = adapterConfig("/source/two"); + + store.createAdapterConfig("stanag", created); + assertSame(created, store.getAdapterConfig("stanag").orElseThrow()); + + store.updateAdapterConfig("stanag", updated); + assertSame(updated, store.getAdapterConfig("stanag").orElseThrow()); + + store.deleteAdapterConfig("stanag"); + + assertTrue(store.getAdapterConfig("stanag").isEmpty()); + assertEquals(3, config.getSaveCount()); + } + + @Test + void adapters_replaceAll_persistsMap() throws IOException { + SavingTwinManagerConfig config = newConfig(); + TwinConfigurationStore store = new TwinConfigurationStore(config); + Map adapters = new LinkedHashMap<>(); + adapters.put("stanag", adapterConfig("/stanag/source")); + adapters.put("custom", adapterConfig("/custom/source")); + + store.replaceAdapterConfigs(adapters); + + assertEquals(2, store.listAdapterConfigs().size()); + assertEquals("/stanag/source", store.getAdapterConfig("stanag").orElseThrow().getProperty("topic")); + assertEquals(1, config.getSaveCount()); + } + + private SavingTwinManagerConfig newConfig() { + SavingTwinManagerConfig config = new SavingTwinManagerConfig(); + config.setN2KTwinConfig(new N2KTwinConfig()); + return config; + } + + private MavlinkTwinConfigDTO mavlinkSource(String name, String topic) { + MavlinkTwinConfigDTO config = new MavlinkTwinConfigDTO(); + config.setName(name); + config.setTopic(topic); + config.setDialectName("common"); + return config; + } + + private DroneInfo drone(String name) { + DroneInfo droneInfo = new DroneInfo(); + droneInfo.setName(name); + droneInfo.setUuid(UUID.randomUUID()); + return droneInfo; + } + + private TakProtocolDTO takConfig(String hostname, int port) { + TakProtocolDTO takProtocolDTO = new TakProtocolDTO(); + takProtocolDTO.setHostname(hostname); + takProtocolDTO.setPort(port); + takProtocolDTO.setTopic("/tak/cot"); + return takProtocolDTO; + } + + private TwinPublishConfigDTO publishConfig(String topicTemplate) { + TwinPublishConfigDTO publishConfig = new TwinPublishConfigDTO(); + publishConfig.setEnabled(true); + publishConfig.setTopicTemplate(topicTemplate); + return publishConfig; + } + + private ConfigurationProperties adapterConfig(String topic) { + ConfigurationProperties adapterConfig = new ConfigurationProperties(); + adapterConfig.put("topic", topic); + adapterConfig.put("enabled", true); + return adapterConfig; + } + + private static class SavingTwinManagerConfig extends TwinManagerConfig { + + private int saveCount; + + @Override + public void save() { + saveCount++; + } + + int getSaveCount() { + return saveCount; + } + } +} diff --git a/src/test/java/io/mapsmessaging/state/config/TwinManagerConfigTest.java b/src/test/java/io/mapsmessaging/state/config/TwinManagerConfigTest.java index e71a82457..f083e8593 100644 --- a/src/test/java/io/mapsmessaging/state/config/TwinManagerConfigTest.java +++ b/src/test/java/io/mapsmessaging/state/config/TwinManagerConfigTest.java @@ -26,8 +26,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import io.mapsmessaging.configuration.ConfigurationProperties; +import io.mapsmessaging.dto.rest.config.protocol.impl.TakProtocolDTO; +import io.mapsmessaging.state.config.capability.TaskCapabilities; import io.mapsmessaging.state.config.n2k.N2KTwinConfig; import java.lang.reflect.Constructor; +import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.Test; class TwinManagerConfigTest { @@ -94,6 +98,73 @@ void toConfigurationProperties_writesAllAdapterBlocksBack() throws ReflectiveOpe assertNull(saved.get("bravoAdapter")); } + @Test + void toConfigurationProperties_writesDroneInfoBack() throws ReflectiveOperationException { + UUID droneUuid = UUID.randomUUID(); + ConfigurationProperties root = new ConfigurationProperties(); + root.put("droneInfo", List.of(droneInfoProperties("drone-alpha", droneUuid))); + + TwinManagerConfig config = newTwinManagerConfig(root); + ConfigurationProperties saved = config.toConfigurationProperties(); + List savedDroneInfos = assertInstanceOf(List.class, saved.get("droneInfo")); + ConfigurationProperties savedDroneInfo = assertInstanceOf(ConfigurationProperties.class, savedDroneInfos.get(0)); + + assertEquals("drone-alpha", savedDroneInfo.getProperty("name")); + assertEquals(droneUuid.toString(), savedDroneInfo.getProperty("uuid")); + assertInstanceOf(ConfigurationProperties.class, savedDroneInfo.get("description")); + assertInstanceOf(ConfigurationProperties.class, savedDroneInfo.get("capabilities")); + } + + @Test + void toConfigurationProperties_writesN2kFieldsBack() throws ReflectiveOperationException { + ConfigurationProperties root = new ConfigurationProperties(); + ConfigurationProperties n2k = new ConfigurationProperties(); + n2k.put("enabled", true); + n2k.put("topic", "/canbus1/n2k/json/#"); + n2k.put("name", "canbus1"); + n2k.put("vehicleClass", "USV"); + n2k.put("publishMavlinkDrones", false); + root.put("n2k", n2k); + + TwinManagerConfig config = newTwinManagerConfig(root); + ConfigurationProperties saved = config.toConfigurationProperties(); + ConfigurationProperties savedN2k = assertInstanceOf(ConfigurationProperties.class, saved.get("n2k")); + + assertEquals(true, savedN2k.get("enabled")); + assertEquals("/canbus1/n2k/json/#", savedN2k.getProperty("topic")); + assertEquals("canbus1", savedN2k.getProperty("name")); + assertEquals("USV", savedN2k.getProperty("vehicleClass")); + assertEquals(false, savedN2k.get("publishMavlinkDrones")); + } + + @Test + void toConfigurationProperties_omitsDefaultDisabledN2kBlock() throws ReflectiveOperationException { + ConfigurationProperties root = new ConfigurationProperties(); + + TwinManagerConfig config = newTwinManagerConfig(root); + ConfigurationProperties saved = config.toConfigurationProperties(); + + assertNull(saved.get("n2k")); + } + + @Test + void constructor_allowsDroneInfoWithoutDescription() throws ReflectiveOperationException { + UUID droneUuid = UUID.randomUUID(); + ConfigurationProperties root = new ConfigurationProperties(); + ConfigurationProperties droneInfo = new ConfigurationProperties(); + droneInfo.put("name", "drone-alpha"); + droneInfo.put("uuid", droneUuid.toString()); + root.put("droneInfo", List.of(droneInfo)); + + TwinManagerConfig config = newTwinManagerConfig(root); + + assertEquals(1, config.getDroneInfo().size()); + assertEquals("drone-alpha", config.getDroneInfo().get(0).getName()); + assertEquals(droneUuid, config.getDroneInfo().get(0).getUuid()); + assertNull(config.getDroneInfo().get(0).getDescription()); + assertInstanceOf(TaskCapabilities.class, config.getDroneInfo().get(0).getCapabilities()); + } + @Test void update_copiesAdapterConfigMap() { TwinManagerConfig config = new TwinManagerConfig(); @@ -110,6 +181,73 @@ void update_copiesAdapterConfigMap() { assertSame(adapter, config.getAdapterConfig().get("alphaAdapter")); } + @Test + void constructor_readsConfigurationWrittenByToConfigurationProperties() throws ReflectiveOperationException { + UUID droneUuid = UUID.randomUUID(); + TwinManagerConfig original = new TwinManagerConfig(); + original.setHeartbeatTimeoutMillis(7000); + original.setStaleTimeoutMillis(14000); + original.setRetentionTimeoutMillis(28000); + original.setRemoveExpiredTwins(false); + original.setDefaultRootPath("/it"); + + TakProtocolDTO tak = new TakProtocolDTO(); + tak.setHostname("127.0.0.1"); + tak.setPort(8088); + tak.setSharedConnection(true); + tak.setTopic("tak/events"); + original.setTak(tak); + + TwinPublishConfigDTO publish = new TwinPublishConfigDTO(); + publish.setEnabled(true); + publish.setTopicTemplate("/it/twins/{twinId}"); + original.setPublish(publish); + + N2KTwinConfig n2k = new N2KTwinConfig(); + n2k.setEnable(true); + n2k.setPublishMavlinkDrones(false); + n2k.setTopic("/it/n2k/json/#"); + n2k.setName("it-n2k"); + n2k.setVehicleClass("USV"); + original.setN2KTwinConfig(n2k); + + MavlinkTwinConfigDTO mavlink = new MavlinkTwinConfigDTO(); + mavlink.setName("it-mavlink"); + mavlink.setTopic("/it/mavlink/#"); + mavlink.setDialectName("common"); + original.getMavlink().add(mavlink); + original.getDroneInfo().add(droneInfo("it-drone", droneUuid)); + original.getAdapterConfig().put("itAdapter", adapterProperties("/it/adapter", 15)); + + TwinManagerConfig reloaded = newTwinManagerConfig(original.toConfigurationProperties()); + + assertEquals(7000, reloaded.getHeartbeatTimeoutMillis()); + assertEquals(14000, reloaded.getStaleTimeoutMillis()); + assertEquals(28000, reloaded.getRetentionTimeoutMillis()); + assertEquals(false, reloaded.isRemoveExpiredTwins()); + assertEquals("/it", reloaded.getDefaultRootPath()); + assertEquals("127.0.0.1", reloaded.getTak().getHostname()); + assertEquals(8088, reloaded.getTak().getPort()); + assertEquals(true, reloaded.getTak().isSharedConnection()); + assertEquals("tak/events", reloaded.getTak().getTopic()); + assertEquals(true, reloaded.getPublish().isEnabled()); + assertEquals("/it/twins/{twinId}", reloaded.getPublish().getTopicTemplate()); + assertEquals(true, reloaded.getN2KTwinConfig().isEnable()); + assertEquals(false, reloaded.getN2KTwinConfig().isPublishMavlinkDrones()); + assertEquals("/it/n2k/json/#", reloaded.getN2KTwinConfig().getTopic()); + assertEquals("it-n2k", reloaded.getN2KTwinConfig().getName()); + assertEquals("USV", reloaded.getN2KTwinConfig().getVehicleClass()); + assertEquals(1, reloaded.getMavlink().size()); + assertEquals("it-mavlink", reloaded.getMavlink().get(0).getName()); + assertEquals("/it/mavlink/#", reloaded.getMavlink().get(0).getTopic()); + assertEquals("common", reloaded.getMavlink().get(0).getDialectName()); + assertEquals(1, reloaded.getDroneInfo().size()); + assertEquals("it-drone", reloaded.getDroneInfo().get(0).getName()); + assertEquals(droneUuid, reloaded.getDroneInfo().get(0).getUuid()); + assertEquals(1, reloaded.getAdapterConfig().size()); + assertEquals("/it/adapter", reloaded.getAdapterConfig().get("itAdapter").getProperty("topic")); + } + private ConfigurationProperties adapterProperties(String topic, int interval) { ConfigurationProperties properties = new ConfigurationProperties(); properties.put("enabled", true); @@ -130,6 +268,25 @@ private ConfigurationProperties stateAdapterConfig( return stateAdapters; } + private ConfigurationProperties droneInfoProperties(String name, UUID uuid) { + ConfigurationProperties properties = new ConfigurationProperties(); + ConfigurationProperties description = new ConfigurationProperties(); + description.put("manufacturer", "MapsMessaging"); + properties.put("name", name); + properties.put("uuid", uuid.toString()); + properties.put("description", description); + properties.put("capabilities", new ConfigurationProperties()); + return properties; + } + + private DroneInfo droneInfo(String name, UUID uuid) { + DroneInfo droneInfo = new DroneInfo(); + droneInfo.setName(name); + droneInfo.setUuid(uuid); + droneInfo.setCapabilities(new TaskCapabilities()); + return droneInfo; + } + private TwinManagerConfig newTwinManagerConfig(ConfigurationProperties properties) throws ReflectiveOperationException { Constructor constructor = TwinManagerConfig.class.getDeclaredConstructor(ConfigurationProperties.class);