diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index 54d2494679..2ff54604c7 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -17,6 +17,7 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; 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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,12 +33,16 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; @@ -67,6 +72,7 @@ import java.util.List; import java.util.Set; import java.util.UUID; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; @@ -215,6 +221,7 @@ public class TbResourceController extends BaseController { "\n\nResource combination of the title with the key is unique in the scope of tenant. " + "Remove 'id', 'tenantId' from the request body example (below) to create new Resource entity." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @Deprecated // resource should be save or update with an upload request @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PostMapping(value = "/resource") public TbResourceInfo saveResource(@Parameter(description = "A JSON value representing the Resource.") @@ -224,6 +231,66 @@ public class TbResourceController extends BaseController { return tbResourceService.save(resource, getCurrentUser()); } + @ApiOperation(value = "Upload Resource via Multipart File (uploadResource)", + notes = "Upload the Resource using multipart file upload. " + + "When creating the Resource, platform generates Resource id as " + UUID_WIKI_LINK + + "The newly created Resource id will be present in the response. " + + "Specify existing Resource id to update the Resource. " + + "Referencing non-existing Resource Id will cause 'Not Found' error. " + + "\n\nResource combination of the title with the key is unique in the scope of tenant. " + + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(mediaType = MULTIPART_FORM_DATA_VALUE))) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @PostMapping(value = "/resource/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public TbResourceInfo uploadResource(@Parameter(description = RESOURCE_ID_PARAM_DESCRIPTION) + @RequestParam(name = RESOURCE_ID, required = false) UUID resourceId, + @Parameter(description = "Resource title.", example = "Title") + @RequestParam(name = "title", required = false) String title, + @Parameter(description = "Resource type.", schema = @Schema(implementation = ResourceType.class, nullable = true, example = "GENERAL")) + @RequestParam(name = "resourceType") ResourceType resourceType, + @Parameter(description = "Resource descriptor (JSON).") + @RequestParam(name = "descriptor", required = false) String descriptor, + @Parameter(description = "Resource search text.") + @RequestParam(name = "searchText", required = false) String searchText, + @Parameter(description = "Resource file.") + @RequestPart MultipartFile file) throws Exception { + TbResource resource = new TbResource(); + resource.setTenantId(getTenantId()); + resource.setId(resourceId != null ? new TbResourceId(resourceId) : null); + resource.setTitle(StringUtils.isNotEmpty(title) ? title : file.getOriginalFilename()); + resource.setResourceType(resourceType); + + if (StringUtils.isNotEmpty(descriptor)) { + resource.setDescriptor(JacksonUtil.toJsonNode(descriptor)); + } else { + String mediaType = resourceType.getMediaType() != null ? resourceType.getMediaType() : file.getContentType(); + resource.setDescriptor(JacksonUtil.newObjectNode().put("mediaType", mediaType)); + } + + resource.setSearchText(StringUtils.isNotEmpty(searchText) ? searchText : resource.getTitle()); + resource.setFileName(file.getOriginalFilename()); + resource.setData(file.getBytes()); + + checkEntity(resource.getId(), resource, Resource.TB_RESOURCE); + return tbResourceService.save(resource, getCurrentUser()); + } + + @ApiOperation(value = "Update Resource title", + notes = "Updates the title of the existing Resource by resourceId. " + + "Only the title can be updated. " + + "Referencing a non-existing Resource Id will cause a 'Not Found' error. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @PutMapping(value = "/resource/{id}/title") + public TbResourceInfo updateResourceTitle(@Parameter(description = "Unique identifier of the Resource to update", required = true) + @PathVariable UUID id, + @Parameter(description = "New title for the Resource", example = "Title", required = true) + @RequestBody String title) throws Exception { + TbResourceId tbResourceId = new TbResourceId(id); + TbResource resourceInfo = new TbResource(checkResourceInfoId(tbResourceId, Operation.WRITE)); + resourceInfo.setTitle(title); + return tbResourceService.save(resourceInfo, getCurrentUser()); + } + @ApiOperation(value = "Get Resource Infos (getResources)", notes = "Returns a page of Resource Info objects owned by tenant or sysadmin. " + PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) diff --git a/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java b/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java index 28ec9af4b2..43d243eeb8 100644 --- a/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java +++ b/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java @@ -75,7 +75,7 @@ public class DefaultTbResourceService extends AbstractTbEntityService implements ActionType actionType = resource.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = resource.getTenantId(); try { - if (ResourceType.LWM2M_MODEL.equals(resource.getResourceType())) { + if (ResourceType.LWM2M_MODEL.equals(resource.getResourceType()) && resource.getId() == null) { toLwm2mResource(resource); } else if (resource.getResourceKey() == null) { resource.setResourceKey(resource.getFileName()); diff --git a/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java b/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java index 2e6e43e7bf..6ae186b83f 100644 --- a/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java +++ b/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java @@ -19,8 +19,8 @@ import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceDeleteResult; -import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.TbResourceInfo; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.lwm2m.LwM2mObject; diff --git a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java index 352e1fcfeb..e99b0a1091 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java @@ -25,11 +25,12 @@ import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockPart; import org.springframework.test.web.servlet.ResultActions; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Dashboard; -import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; @@ -47,15 +48,14 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; -import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; import java.util.Base64; -import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Objects; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; @@ -64,7 +64,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @DaoSqlTest public class TbResourceControllerTest extends AbstractControllerTest { - private IdComparator idComparator = new IdComparator<>(); + private final IdComparator idComparator = new IdComparator<>(); private static final String DEFAULT_FILE_NAME = "test.jks"; private static final String DEFAULT_FILE_NAME_2 = "test2.jks"; @@ -126,13 +126,9 @@ public class TbResourceControllerTest extends AbstractControllerTest { Assert.assertEquals(DEFAULT_FILE_NAME, savedResource.getResourceKey()); Assert.assertArrayEquals(resource.getData(), download(savedResource.getId())); - TbResource foundResource = doGet("/api/resource/" + savedResource.getId().getId().toString(), TbResource.class); - foundResource.setTitle("My new resource"); - foundResource.setData(null); - - savedResource = save(foundResource); - - Assert.assertEquals(foundResource.getTitle(), savedResource.getTitle()); + String resourceTitle = "My new resource"; + savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/title", resourceTitle, TbResourceInfo.class); + assertThat(savedResource.getTitle()).isEqualTo(resourceTitle); testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(savedResource, savedResource.getId(), savedResource.getId(), savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), @@ -501,8 +497,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, cntEntity, cntEntity, cntEntity); - Collections.sort(resources, idComparator); - Collections.sort(loadedResources, idComparator); + resources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(resources, loadedResources); } @@ -549,8 +545,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, jksCntEntity + lwm2mCntEntity, jksCntEntity + lwm2mCntEntity, jksCntEntity + lwm2mCntEntity); - Collections.sort(resources, idComparator); - Collections.sort(loadedResources, idComparator); + resources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(resources, loadedResources); } @@ -581,8 +577,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Collections.sort(resources, idComparator); - Collections.sort(loadedResources, idComparator); + resources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(resources, loadedResources); @@ -654,8 +650,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Collections.sort(jksResources, idComparator); - Collections.sort(loadedResources, idComparator); + jksResources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(jksResources, loadedResources); @@ -736,8 +732,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Collections.sort(expectedResources, idComparator); - Collections.sort(loadedResources, idComparator); + expectedResources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(expectedResources, loadedResources); @@ -770,7 +766,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { MockHttpServletResponse response = resultActions.andReturn().getResponse(); String eTag = response.getHeader("ETag"); Assert.assertNotNull(eTag); - Assert.assertEquals(Base64.getEncoder().encodeToString(response.getContentAsByteArray()), TEST_DATA); + Assert.assertEquals(TEST_DATA, Base64.getEncoder().encodeToString(response.getContentAsByteArray())); //download with if-none-match header HttpHeaders headers = new HttpHeaders(); @@ -814,7 +810,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { MockHttpServletResponse response = resultActions.andReturn().getResponse(); String eTag = response.getHeader("ETag"); Assert.assertNotNull(eTag); - Assert.assertEquals(Base64.getEncoder().encodeToString(response.getContentAsByteArray()), TEST_DATA); + Assert.assertEquals(TEST_DATA, Base64.getEncoder().encodeToString(response.getContentAsByteArray())); //download with if-none-match header HttpHeaders headers = new HttpHeaders(); @@ -859,10 +855,9 @@ public class TbResourceControllerTest extends AbstractControllerTest { .andExpect(status().isBadRequest()) .andExpect(statusReason(containsString("can't be updated"))); - foundResource.setData(null); - foundResource.setTitle("Updated resource"); - savedResource = doPost("/api/resource", foundResource, TbResource.class); - assertThat(savedResource.getTitle()).isEqualTo("Updated resource"); + String resourceTitle = "Updated resource"; + savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/title", resourceTitle, TbResourceInfo.class); + assertThat(savedResource.getTitle()).isEqualTo(resourceTitle); assertThat(savedResource.getFileName()).isEqualTo(resource.getFileName()); assertThat(savedResource.getEtag()).isEqualTo(resource.getEtag()); assertThat(download(savedResource.getId())).asBase64Encoded().isEqualTo(TEST_DATA); @@ -923,8 +918,24 @@ public class TbResourceControllerTest extends AbstractControllerTest { } private TbResourceInfo save(TbResource tbResource) throws Exception { - return doPostWithTypedResponse("/api/resource", tbResource, new TypeReference<>() { - }); + byte[] data = tbResource.getData() != null ? tbResource.getData() : tbResource.getEncodedData() != null ? Base64.getDecoder().decode(tbResource.getEncodedData()) : null; + List parts = new ArrayList<>(); + parts.add(new MockPart("resourceType", tbResource.getResourceType().name().getBytes())); + + if (tbResource.getId() != null) { + parts.add(new MockPart("resourceId", tbResource.getId().getId().toString().getBytes())); + } + if (tbResource.getTitle() != null) { + parts.add(new MockPart("title", tbResource.getTitle().getBytes())); + } + if (tbResource.getDescriptor() != null) { + parts.add(new MockPart("descriptor", tbResource.getDescriptor().toString().getBytes())); + } + if (tbResource.getSearchText() != null) { + parts.add(new MockPart("searchText", tbResource.getSearchText().getBytes())); + } + + return uploadResource(HttpMethod.POST, "/api/resource/upload", tbResource.getFileName(), tbResource.getResourceType().getMediaType(), data, parts); } private TbResourceInfo findResourceInfo(TbResourceId id) throws Exception { @@ -949,7 +960,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { for (String model : models) { String fileName = model + ".xml"; - byte[] bytes = IOUtils.toByteArray(getClass().getClassLoader().getResourceAsStream("lwm2m/" + fileName)); + byte[] bytes = IOUtils.toByteArray(Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream("lwm2m/" + fileName))); TbResource resource = new TbResource(); resource.setResourceType(ResourceType.LWM2M_MODEL); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java index a3bf383503..77de18e446 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; +import java.io.Serial; import java.util.function.UnaryOperator; @Schema @@ -36,6 +37,7 @@ import java.util.function.UnaryOperator; @EqualsAndHashCode(callSuper = true) public class TbResourceInfo extends BaseData implements HasName, HasTenantId, ExportableEntity { + @Serial private static final long serialVersionUID = 7282664529021651736L; @Schema(description = "JSON object with Tenant Id. Tenant Id of the resource can't be changed.", accessMode = Schema.AccessMode.READ_ONLY) diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java index 7f0dd4a6bf..478fecb037 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java @@ -89,7 +89,9 @@ import static org.thingsboard.server.dao.service.Validator.validateId; @Primary public class BaseResourceService extends AbstractCachedEntityService implements ResourceService { - public static final String INCORRECT_RESOURCE_ID = "Incorrect resourceId "; + protected static final String INCORRECT_RESOURCE_ID = "Incorrect resourceId "; + protected static final int MAX_ENTITIES_TO_FIND = 10; + protected final TbResourceDao resourceDao; protected final TbResourceInfoDao resourceInfoDao; protected final ResourceDataValidator resourceValidator; @@ -98,7 +100,6 @@ public class BaseResourceService extends AbstractCachedEntityService> resourceLinkContainerDaoMap = new HashMap<>(); private final Map> generalResourceContainerDaoMap = new HashMap<>(); - protected static final int MAX_ENTITIES_TO_FIND = 10; @PostConstruct public void init() { @@ -275,7 +276,7 @@ public class BaseResourceService extends AbstractCachedEntityService { @Override protected TbResource validateUpdate(TenantId tenantId, TbResource resource) { - if (resource.getData() != null && !resource.getResourceType().isUpdatable() && - tenantId != null && !tenantId.isSysTenantId()) { + if ((resource.getData() != null && !resource.getResourceType().isUpdatable() && tenantId != null && !tenantId.isSysTenantId()) + || resource.getResourceType().equals(ResourceType.LWM2M_MODEL)) { throw new DataValidationException("This type of resource can't be updated"); } return resource; @@ -81,7 +83,7 @@ public class ResourceDataValidator extends DataValidator { if (StringUtils.isEmpty(resource.getFileName())) { throw new DataValidationException("Resource file name should be specified!"); } - if (StringUtils.containsAny(resource.getFileName(), "/", "\\")) { + if (Strings.CS.containsAny(resource.getFileName(), "/", "\\")) { throw new DataValidationException("File name contains forbidden symbols"); } if (StringUtils.isEmpty(resource.getResourceKey())) { @@ -104,4 +106,5 @@ public class ResourceDataValidator extends DataValidator { validateMaxSumDataSizePerTenant(tenantId, resourceDao, maxSumResourcesDataInBytes, dataSize, TB_RESOURCE); } } + }