CBOR support for the DDI API (#797)

* Add test for default content-type of DDI

We want to add support for other content types to the DDI interface.
To make sure we don't break devices that implicitly assume they will
receive JSON without setting an Accept header, add a test for the current
behavior.

v2: Complete the assertion

Signed-off-by: Stefan Schake <stefan.schake@devolo.de>

* Add CBOR support for DDI API

Concise Binary Object Representation (CBOR) is a binary data format
optimized for small code and message size. Since Spring Boot 2, there
is an autoconfigured data converter so we need to do little more than
add the reference to the Jackson backend and advertise support in the DDI
endpoints. Add tests to ensure all endpoints support the format.

Fixes #748

Signed-off-by: Stefan Schake <stefan.schake@devolo.de>
This commit is contained in:
Stefan Schake
2019-02-28 18:32:48 +01:00
committed by Dominic Schabel
parent d29bcc0345
commit e8e203b69a
8 changed files with 185 additions and 8 deletions

View File

@@ -29,6 +29,10 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-cbor</artifactId>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>

View File

@@ -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.
}

View File

@@ -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<List<DdiArtifact>> 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<DdiControllerBase> 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<DdiDeploymentBase> 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<Void> 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<Void> 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<DdiCancel> 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<Void> postCancelActionFeedback(@Valid final DdiActionFeedback feedback,
@PathVariable("tenant") final String tenant,
@PathVariable("controllerId") @NotEmpty final String controllerId,

View File

@@ -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();
}
}

View File

@@ -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.<String>evaluate(cborToJson(result), "$.id")).isEqualTo(String.valueOf(cancelAction.getId()));
assertThat(JsonPathUtils.<String>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 {

View File

@@ -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<String, String> 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.")

View File

@@ -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 {

View File

@@ -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,