I have a Spring Boot RESTful API with a PUT endpoint that can be accessed by different roles (ADMIN, MODERATOR, etc.). How to use the same DTO CreateOrUpdateRequest
for all roles but with different field validations & serialization rules depending on the user’s role :
public class CreateOrUpdateRequest {
@NotNull
private String field1;
@NotEmpty
private String field2;
@NotNull
@ValidValueOfEnum(enumClass = MyEnum.class)
private String field3;
}
For example, I want the ADMIN
role to be able to update all the fields, but the MODERATOR
can only update ‘field3’.
-
Should I use a DTO per role ? If so, how can several DTOs be used for the same route and service method?
-
I’ve tried the
@JsonView
approach but the unserialized fields will be null. My Mapstruct mapper will map null fields to null and automatically empty/delete fields :
@Mapper(componentModel = "spring")
public abstract class RequestMapper {
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.SET_TO_NULL, nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
public abstract void update(CreateOrUpdateRequest request, @MappingTarget MyEntity entity);
}
Is @JsonView
the right approach, or is there a better way to handle this in a Spring ?
10
Assume below service layer is accessed by an API, based on the user’s all the roles and permissions each field can be updated.
@Service
public class EntityService {
@Autowired
private EntityRepository entityRepository;
@Autowired
private UserService userService; // Service to get the currently logged-in user
public void updateEntity(Long entityId, Map<String, Object> updates) {
// Get the current user
User currentUser = userService.getCurrentUser();
Role userRole = currentUser.getRole();
// Fetch the entity from the database
Entity entity = entityRepository.findById(entityId)
.orElseThrow(() -> new EntityNotFoundException("Entity not found"));
// Perform field-level checks
if (userRole == Role.USER_A) {
if (updates.containsKey("field2")) {
throw new AccessDeniedException("User A cannot update field2");
}
// Update field1
entity.setField1((String) updates.get("field1"));
} else if (userRole == Role.USER_B) {
if (updates.containsKey("field1")) {
throw new AccessDeniedException("User B cannot update field1");
}
// Update field2
entity.setField2((String) updates.get("field2"));
} else {
throw new AccessDeniedException("Unknown role");
}
// Save the updated entity
entityRepository.save(entity);
}
}
It will be a single API but based on user’s all the permissions defined, validate the permission before updating each field and then allow the operation.
1
Going the route of multiple endpoints and multiple DTO classes, you could implement a @RequestMapping
for each role and just pass that DTO and all required information to a common private method or a service method.
An example would be the following MyController.java with some additional classes, either within the same file or move to seperate files:
@Controller
@RequestMapping("/resources/{id}")
class MyController {
private final Object adminMapper = null;
private final Object managerMapper = null;
@PostMapping("/admin")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> adminUpdate(@Valid @RequestBody AdminDto dto, BindingResult result) {
return update(dto, result, adminMapper);
}
@PostMapping("/manager")
@PreAuthorize("hasRole('MANAGER')")
public ResponseEntity<?> managerUpdate(@Valid @RequestBody ManagerDto dto, BindingResult result) {
return update(dto, result, managerMapper);
}
// alternativly the role instead of the mapper
private ResponseEntity<?> update(UpdateDto dto, BindingResult result, Object mapper) {
if (result.hasErrors()) {
// TODO log and/or pass errors to response
return ResponseEntity.badRequest().build();
}
// TODO implement rest / call service
return ResponseEntity.noContent().build();
}
}
enum MyEnum {
FOO, BAR
}
// no default on fields present in both/all implementations necessary
sealed interface UpdateDto {
default String field1() {
return null;
}
default String field2() {
return null;
}
MyEnum field3();
}
record AdminDto(
@NotNull String field1,
@NotEmpty String field2,
@NotNull MyEnum field3) implements UpdateDto {
}
record ManagerDto(@NotNull MyEnum field3) implements UpdateDto {
}
In this example, the controller decides the mapper to use, but you could also just pass the DTO and the role to a service and let the service handle that instead. Obviously you do not have to use records or sealed interfaces. You could use an (not even necessarily) abstract class that contains field3 and have the admin DTO extend that class:
class UpdateDto {
// TODO field3
}
class AdminUpdateDto extends UpdateDto {
// TODO field 1
// TODO field 2
}
Edit:
As noted, I’m not familiar with MapStruct, so you might have to call that mapper with the actual DTO and not the interface to make sure the null values from the default methods won’t be used.
An alternative would be to just have a Consumer as an argument that does mapping and remove the usage of an interface. Or just remove the private method alltogether and live with some code duplication (bindingResult handling, loading entity and mapping).
1