diff --git a/hawkbit-rest/hawkbit-ddi-api/pom.xml b/hawkbit-rest/hawkbit-ddi-api/pom.xml index fafba9afa..932048a9d 100644 --- a/hawkbit-rest/hawkbit-ddi-api/pom.xml +++ b/hawkbit-rest/hawkbit-ddi-api/pom.xml @@ -29,6 +29,10 @@ com.fasterxml.jackson.core jackson-annotations + + com.fasterxml.jackson.dataformat + jackson-dataformat-cbor + javax.validation validation-api diff --git a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRestConstants.java b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRestConstants.java index bacb70ee4..cf04baa45 100644 --- a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRestConstants.java +++ b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRestConstants.java @@ -50,6 +50,19 @@ public final class DdiRestConstants { */ public static final String NO_ACTION_HISTORY = "0"; + /** + * Media type for CBOR content. Unfortunately, there is no other constant we + * can reuse - even the Jackson data converter simply hardcodes this. + */ + public static final String MEDIA_TYPE_CBOR = "application/cbor"; + + /** + * Media type for CBOR content with strings encoded as UTF-8. Technically + * redundant since CBOR always uses UTF-8, but Spring will append it + * regardless. + */ + public static final String MEDIA_TYPE_CBOR_UTF8 = "application/cbor;charset=UTF-8"; + private DdiRestConstants() { // constant class, private constructor. } diff --git a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRootControllerRestApi.java b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRootControllerRestApi.java index 7b759c773..619ed1849 100644 --- a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRootControllerRestApi.java +++ b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRootControllerRestApi.java @@ -50,7 +50,7 @@ public interface DdiRootControllerRestApi { * @return the response */ @GetMapping(value = "/{controllerId}/softwaremodules/{softwareModuleId}/artifacts", produces = { - MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }) + MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE, DdiRestConstants.MEDIA_TYPE_CBOR }) ResponseEntity> getSoftwareModulesArtifacts(@PathVariable("tenant") final String tenant, @PathVariable("controllerId") final String controllerId, @PathVariable("softwareModuleId") final Long softwareModuleId); @@ -66,7 +66,8 @@ public interface DdiRootControllerRestApi { * the HTTP request injected by spring * @return the response */ - @GetMapping(value = "/{controllerId}", produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }) + @GetMapping(value = "/{controllerId}", produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE, + DdiRestConstants.MEDIA_TYPE_CBOR }) ResponseEntity getControllerBase(@PathVariable("tenant") final String tenant, @PathVariable("controllerId") final String controllerId); @@ -154,7 +155,7 @@ public interface DdiRootControllerRestApi { * @return the response */ @GetMapping(value = "/{controllerId}/" + DdiRestConstants.DEPLOYMENT_BASE_ACTION + "/{actionId}", produces = { - MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }) + MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE, DdiRestConstants.MEDIA_TYPE_CBOR }) ResponseEntity getControllerBasedeploymentAction(@PathVariable("tenant") final String tenant, @PathVariable("controllerId") @NotEmpty final String controllerId, @PathVariable("actionId") @NotEmpty final Long actionId, @@ -178,7 +179,8 @@ public interface DdiRootControllerRestApi { * @return the response */ @PostMapping(value = "/{controllerId}/" + DdiRestConstants.DEPLOYMENT_BASE_ACTION + "/{actionId}/" - + DdiRestConstants.FEEDBACK, consumes = MediaType.APPLICATION_JSON_VALUE) + + DdiRestConstants.FEEDBACK, consumes = { MediaType.APPLICATION_JSON_VALUE, + DdiRestConstants.MEDIA_TYPE_CBOR }) ResponseEntity postBasedeploymentActionFeedback(@Valid final DdiActionFeedback feedback, @PathVariable("tenant") final String tenant, @PathVariable("controllerId") final String controllerId, @PathVariable("actionId") @NotEmpty final Long actionId); @@ -197,8 +199,8 @@ public interface DdiRootControllerRestApi { * * @return status of the request */ - @PutMapping(value = "/{controllerId}/" - + DdiRestConstants.CONFIG_DATA_ACTION, consumes = MediaType.APPLICATION_JSON_VALUE) + @PutMapping(value = "/{controllerId}/" + DdiRestConstants.CONFIG_DATA_ACTION, consumes = { + MediaType.APPLICATION_JSON_VALUE, DdiRestConstants.MEDIA_TYPE_CBOR }) ResponseEntity putConfigData(@Valid final DdiConfigData configData, @PathVariable("tenant") final String tenant, @PathVariable("controllerId") final String controllerId); @@ -217,7 +219,7 @@ public interface DdiRootControllerRestApi { * @return the {@link DdiCancel} response */ @GetMapping(value = "/{controllerId}/" + DdiRestConstants.CANCEL_ACTION + "/{actionId}", produces = { - MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }) + MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE, DdiRestConstants.MEDIA_TYPE_CBOR }) ResponseEntity getControllerCancelAction(@PathVariable("tenant") final String tenant, @PathVariable("controllerId") @NotEmpty final String controllerId, @PathVariable("actionId") @NotEmpty final Long actionId); @@ -241,7 +243,8 @@ public interface DdiRootControllerRestApi { */ @PostMapping(value = "/{controllerId}/" + DdiRestConstants.CANCEL_ACTION + "/{actionId}/" - + DdiRestConstants.FEEDBACK, consumes = MediaType.APPLICATION_JSON_VALUE) + + DdiRestConstants.FEEDBACK, consumes = { MediaType.APPLICATION_JSON_VALUE, + DdiRestConstants.MEDIA_TYPE_CBOR }) ResponseEntity postCancelActionFeedback(@Valid final DdiActionFeedback feedback, @PathVariable("tenant") final String tenant, @PathVariable("controllerId") @NotEmpty final String controllerId, diff --git a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/AbstractDDiApiIntegrationTest.java b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/AbstractDDiApiIntegrationTest.java index b79f55a02..d54a0ed40 100644 --- a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/AbstractDDiApiIntegrationTest.java +++ b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/AbstractDDiApiIntegrationTest.java @@ -8,6 +8,11 @@ */ package org.eclipse.hawkbit.ddi.rest.resource; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; + import org.eclipse.hawkbit.repository.jpa.RepositoryApplicationConfiguration; import org.eclipse.hawkbit.repository.test.TestConfiguration; import org.eclipse.hawkbit.rest.AbstractRestIntegrationTest; @@ -16,9 +21,59 @@ import org.springframework.cloud.stream.test.binder.TestSupportBinderAutoConfigu import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestPropertySource; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import com.fasterxml.jackson.dataformat.cbor.CBORGenerator; +import com.fasterxml.jackson.dataformat.cbor.CBORParser; + @ContextConfiguration(classes = { DdiApiConfiguration.class, RestConfiguration.class, RepositoryApplicationConfiguration.class, TestConfiguration.class, TestSupportBinderAutoConfiguration.class }) @TestPropertySource(locations = "classpath:/ddi-test.properties") public abstract class AbstractDDiApiIntegrationTest extends AbstractRestIntegrationTest { + /** + * Convert JSON to a CBOR equivalent. + * + * @param json + * JSON object to convert + * @return Equivalent CBOR data + * @throws IOException + * Invalid JSON input + */ + protected static byte[] jsonToCbor(String json) throws IOException { + JsonFactory jsonFactory = new JsonFactory(); + JsonParser jsonParser = jsonFactory.createParser(json); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + CBORFactory cborFactory = new CBORFactory(); + CBORGenerator cborGenerator = cborFactory.createGenerator(out); + while (jsonParser.nextToken() != null) { + cborGenerator.copyCurrentEvent(jsonParser); + } + cborGenerator.flush(); + return out.toByteArray(); + } + + /** + * Convert CBOR to JSON equivalent. + * + * @param input + * CBOR data to convert + * @return Equivalent JSON string + * @throws IOException + * Invalid CBOR input + */ + protected static String cborToJson(byte[] input) throws IOException { + CBORFactory cborFactory = new CBORFactory(); + CBORParser cborParser = cborFactory.createParser(input); + JsonFactory jsonFactory = new JsonFactory(); + StringWriter stringWriter = new StringWriter(); + JsonGenerator jsonGenerator = jsonFactory.createGenerator(stringWriter); + while (cborParser.nextToken() != null) { + jsonGenerator.copyCurrentEvent(cborParser); + } + jsonGenerator.flush(); + return stringWriter.toString(); + } } diff --git a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiCancelActionTest.java b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiCancelActionTest.java index bb2817f91..495abe560 100644 --- a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiCancelActionTest.java +++ b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiCancelActionTest.java @@ -23,6 +23,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import java.util.ArrayList; import java.util.List; +import org.eclipse.hawkbit.ddi.rest.api.DdiRestConstants; import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.Action.Status; import org.eclipse.hawkbit.repository.model.DistributionSet; @@ -33,6 +34,8 @@ import org.eclipse.hawkbit.rest.util.MockMvcResultPrinter; import org.junit.Test; import org.springframework.hateoas.MediaTypes; import org.springframework.http.MediaType; +import org.springframework.integration.json.JsonPathUtils; +import org.springframework.test.util.JsonPathExpectationsHelper; import io.qameta.allure.Description; import io.qameta.allure.Feature; @@ -45,6 +48,32 @@ import io.qameta.allure.Story; @Story("Cancel Action Resource") public class DdiCancelActionTest extends AbstractDDiApiIntegrationTest { + @Test + @Description("Tests that the cancel action resource can be used with CBOR.") + public void cancelActionCbor() throws Exception { + final DistributionSet ds = testdataFactory.createDistributionSet(""); + final Target savedTarget = testdataFactory.createTarget(); + final Long actionId = assignDistributionSet(ds.getId(), TestdataFactory.DEFAULT_CONTROLLER_ID).getActions() + .get(0); + final Action cancelAction = deploymentManagement.cancelAction(actionId); + + // check that we can get the cancel action as CBOR + byte[] result = mvc.perform(get("/{tenant}/controller/v1/" + TestdataFactory.DEFAULT_CONTROLLER_ID + "/cancelAction/" + + cancelAction.getId(), tenantAware.getCurrentTenant()).accept(DdiRestConstants.MEDIA_TYPE_CBOR)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(content().contentType(DdiRestConstants.MEDIA_TYPE_CBOR_UTF8)) + .andReturn().getResponse().getContentAsByteArray(); + assertThat(JsonPathUtils.evaluate(cborToJson(result), "$.id")).isEqualTo(String.valueOf(cancelAction.getId())); + assertThat(JsonPathUtils.evaluate(cborToJson(result), "$.cancelAction.stopId")).isEqualTo(String.valueOf(actionId)); + + // and submit feedback as CBOR + mvc.perform(post("/{tenant}/controller/v1/" + TestdataFactory.DEFAULT_CONTROLLER_ID + "/cancelAction/" + + cancelAction.getId() + "/feedback", tenantAware.getCurrentTenant()).content( + jsonToCbor(JsonBuilder.cancelActionFeedback(cancelAction.getId().toString(), "proceeding"))) + .contentType(DdiRestConstants.MEDIA_TYPE_CBOR).accept(DdiRestConstants.MEDIA_TYPE_CBOR)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + } + @Test @Description("Test of the controller can continue a started update even after a cancel command if it so desires.") public void rootRsCancelActionButContinueAnyway() throws Exception { diff --git a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfigDataTest.java b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfigDataTest.java index b0c60f6c3..8e675ce65 100644 --- a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfigDataTest.java +++ b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfigDataTest.java @@ -22,6 +22,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import org.eclipse.hawkbit.ddi.rest.api.DdiRestConstants; import org.eclipse.hawkbit.exception.SpServerError; import org.eclipse.hawkbit.repository.exception.InvalidTargetAttributeException; import org.eclipse.hawkbit.repository.exception.QuotaExceededException; @@ -54,6 +55,21 @@ public class DdiConfigDataTest extends AbstractDDiApiIntegrationTest { Target.CONTROLLER_ATTRIBUTE_VALUE_SIZE + 1); private static final String VALUE_VALID = generateRandomStringWithLength(Target.CONTROLLER_ATTRIBUTE_VALUE_SIZE); + @Test + @Description("Verify that config data can be uploaded as CBOR") + public void putConfigDataAsCbor() throws Exception { + testdataFactory.createTarget("4717"); + + final Map attributes = new HashMap<>(); + attributes.put(KEY_VALID, VALUE_VALID); + + mvc.perform(put("/{tenant}/controller/v1/4717/configData", tenantAware.getCurrentTenant()) + .content(jsonToCbor(JsonBuilder.configData("", attributes, "closed").toString())) + .contentType(DdiRestConstants.MEDIA_TYPE_CBOR)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isOk()); + assertThat(targetManagement.getControllerAttributes("4717")).isEqualTo(attributes); + } + @Test @Description("We verify that the config data (i.e. device attributes like serial number, hardware revision etc.) " + "are requested only once from the device.") diff --git a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiDeploymentBaseTest.java b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiDeploymentBaseTest.java index 8e60a2cf8..5a342edc2 100644 --- a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiDeploymentBaseTest.java +++ b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiDeploymentBaseTest.java @@ -29,6 +29,7 @@ import java.util.concurrent.TimeUnit; import org.apache.commons.lang3.RandomUtils; import org.assertj.core.api.Condition; +import org.eclipse.hawkbit.ddi.rest.api.DdiRestConstants; import org.eclipse.hawkbit.repository.event.remote.TargetAssignDistributionSetEvent; import org.eclipse.hawkbit.repository.event.remote.entity.ActionCreatedEvent; import org.eclipse.hawkbit.repository.event.remote.entity.DistributionSetCreatedEvent; @@ -71,6 +72,40 @@ public class DdiDeploymentBaseTest extends AbstractDDiApiIntegrationTest { private static final String HTTP_LOCALHOST = "http://localhost:8080/"; + @Test + @Description("Ensure that the deployment resource is available as CBOR") + public void deploymentResourceCbor() throws Exception { + final Target target = testdataFactory.createTarget(); + final DistributionSet distributionSet = testdataFactory.createDistributionSet(""); + + assignDistributionSet(distributionSet.getId(), target.getName()); + final Action uaction = deploymentManagement.findActiveActionsByTarget(PAGE, target.getControllerId()) + .getContent().get(0); + + // get deployment base + mvc.perform(get("/{tenant}/controller/v1/{target}/deploymentBase/" + uaction.getId(), + tenantAware.getCurrentTenant(), target.getControllerId()).accept(DdiRestConstants.MEDIA_TYPE_CBOR)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(content().contentType(DdiRestConstants.MEDIA_TYPE_CBOR_UTF8)); + + final Long softwareModuleId = distributionSet.getModules().stream().findAny().get().getId(); + testdataFactory.createArtifacts(softwareModuleId); + // get artifacts + mvc.perform(get("/{tenant}/controller/v1/{target}/softwaremodules/{softwareModuleId}/artifacts", + tenantAware.getCurrentTenant(), target.getControllerId(), softwareModuleId) + .accept(DdiRestConstants.MEDIA_TYPE_CBOR)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(content().contentType(DdiRestConstants.MEDIA_TYPE_CBOR_UTF8)); + + // submit feedback + final byte[] feedback = jsonToCbor( + JsonBuilder.deploymentActionFeedback(uaction.getId().toString(), "proceeding")); + mvc.perform(post("/{tenant}/controller/v1/{target}/deploymentBase/" + uaction.getId() + "/feedback", + tenantAware.getCurrentTenant(), target.getControllerId()).content(feedback) + .contentType(DdiRestConstants.MEDIA_TYPE_CBOR).accept(DdiRestConstants.MEDIA_TYPE_CBOR)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + } + @Test @Description("Ensures that artifacts are not found, when softare module does not exists.") public void artifactsNotFound() throws Exception { diff --git a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootControllerTest.java b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootControllerTest.java index e31fd0d6b..2a431802a 100644 --- a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootControllerTest.java +++ b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootControllerTest.java @@ -30,6 +30,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import java.util.Collections; import java.util.Map; +import org.eclipse.hawkbit.ddi.rest.api.DdiRestConstants; import org.eclipse.hawkbit.im.authentication.SpPermission; import org.eclipse.hawkbit.repository.event.remote.TargetAssignDistributionSetEvent; import org.eclipse.hawkbit.repository.event.remote.TargetAttributesRequestedEvent; @@ -59,6 +60,7 @@ import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.hateoas.MediaTypes; import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultActions; import io.qameta.allure.Description; @@ -79,6 +81,26 @@ public class DdiRootControllerTest extends AbstractDDiApiIntegrationTest { @Autowired private HawkbitSecurityProperties securityProperties; + @Test + @Description("Ensure that the root poll resource is available as CBOR") + public void rootPollResourceCbor() throws Exception { + mvc.perform(get("/{tenant}/controller/v1/4711", tenantAware.getCurrentTenant()) + .accept(DdiRestConstants.MEDIA_TYPE_CBOR)).andDo(MockMvcResultPrinter.print()) + .andExpect(content().contentType(DdiRestConstants.MEDIA_TYPE_CBOR_UTF8)).andExpect(status().isOk()); + } + + @Test + @Description("Ensures that the API returns JSON when no Accept header is specified by the client.") + public void apiReturnsJSONByDefault() throws Exception { + final MvcResult result = mvc.perform(get("/{tenant}/controller/v1/4711", tenantAware.getCurrentTenant())) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(content().contentType(MediaTypes.HAL_JSON_UTF8)).andReturn(); + + // verify that we did not specify a content-type in the request, in case + // there are any default values + assertThat(result.getRequest().getHeader("Accept")).isNull(); + } + @Test @Description("Ensures that targets cannot be created e.g. in plug'n play scenarios when tenant does not exists but can be created if the tenant exists.") @WithUser(tenantId = "tenantDoesNotExists", allSpPermissions = true, authorities = { CONTROLLER_ROLE,