If you have to implement sophisticated business logic, then you most likely will face a necessity to extend some CRUD operations. Some practices can be found here: . But what if you deal with REST?
Assume we create a multi user system, so the right place to check the state the updated/created entity is the back-end. Of course we can put some checking on a button UPDATE on our site/web application. But what if the data became obsolete and UPDATE button is active, when it shouldn’t be? We intend to verify the updated entity Topic, in particular we want to allow creation of a Topic if it has a particular author «beloved_user» and modification of a Topic, only if it has 3 or more Tags and status OPEN. I know, the logic is synthetic, but it’s just a demo, proof of concept.
We want our operations to become atomic and hence we will apply transactions.
Please be attentive, there are no minor aspects in this area 🙂
Introductory. We are going to deal with entities of an abstract internet forum: Topic, it’s nested entity TopicStatus, it’s User (author), and list of tags List<Tag>, list of posts List<Post>. We have the associations as follows:
Topic — TopicStatus many-to-one
Topic — User many-to-one
Topic — Tag many-to-many
Topic — Post one-to-many
TopicStatuses are OPEN and CLOSED.
Processing of HTTP requests is handled by auto-generated stuff, if you use proper interfaces and annotations.
Sample repo before changes:
@RepositoryRestResource public interface TopicRepo extends CrudRepository<Topic, Long> { // delete operation isn't exposed via rest @Override @RestResource(exported = false) void delete(Long id); @Override @RestResource(exported = false) void delete(Topic entity); } |
Now we tell spring we don’t want it to handle HTTP requests for save operation:
@RepositoryRestResource public interface TopicRepo extends CrudRepository<Topic, Long> { // delete operation isn't exposed via rest @Override @RestResource(exported = false) void delete(Long id); @Override @RestResource(exported = false) void delete(Topic entity); // We don't expose this method via rest here as we want to extend the logic. // It is exposed in TopicController. @Override @RestResource(exported=false) Topic save(Topic topic); } |
There will be no autogenerated handling for REST requests to create (PUT) and update (POST) operations, we need to compose them. But you still can call topicRepo.save(Topic topic).
I will include minimally required set of imports.
First things first. We need to create a controller:
import org.springframework.data.rest.webmvc.PersistentEntityResource; import org.springframework.data.rest.webmvc.RepositoryRestController; import org.springframework.hateoas.ResourceProcessor; @RepositoryRestController @RequestMapping(value = "topics") public class TopicController implements ResourceProcessor<PersistentEntityResource> { @Autowired private TopicRepo topicRepo; @Autowired private TopicStatusRepo topicStatusRepo; @Autowired private UserRepo userRepo; @Autowired private TagRepo tagRepo; @Autowired private PostRepo postRepo; ... } |
Now we handle POST HTTP request and corresponding create operation in this controller:
// We have to implement a handler for POST requests, // since we intentionally don't expose it in TopicRepo. // No addition to the path in @RequestMapping, we still expect this requests on end point "topics" @RequestMapping(method = RequestMethod.POST, consumes = APPLICATION_JSON_VALUE, produces = MediaTypes.HAL_JSON_VALUE) @ResponseBody public ResponseEntity<PersistentEntityResource> saveTopic(@RequestBody Resource<Topic> topicBody, PersistentEntityResourceAssembler persistentEntityResourceAssembler) { Topic topicToBeCreated = topicBody.getContent(); Topic createdTopic; String reason = null; boolean headerNotEmpty = false; HttpStatus status = HttpStatus.OK; if (topicToBeCreated != null) { if (topicToBeCreated.getUser().getNickname().equals("beloved_user")) { ImmutablePair<Topic, String> result = saveTheTopic(topicToBeCreated); reason = result.getRight(); headerNotEmpty = StringUtils.isNotBlank(reason); if (headerNotEmpty) { status = HttpStatus.NO_CONTENT; // this code means error in our configuration } createdTopic = result.getLeft(); } else { // Here we handle general cases as opposed to when we need // to check some data or nested resources createdTopic = topicRepo.save(topicToBeCreated); } } else { HttpHeaders responseHeaders = new HttpHeaders(); responseHeaders.set(Constants.RESPONSE_REASON_HEADER, "topic to be created is null"); return new ResponseEntity<>( persistentEntityResourceAssembler.toResource(topicToBeCreated), responseHeaders, HttpStatus.NO_CONTENT); } HttpHeaders headers = new HttpHeaders(); if (headerNotEmpty) { headers.add(Constants.RESPONSE_REASON_HEADER, reason); } if (createdTopic != null && createdTopic.getId() != null) { // topic has been saved in DB return new ResponseEntity<>( persistentEntityResourceAssembler.toResource(createdTopic), headers, status); } else { // Topic hasn't been saved in DB. // It's a desperate measure: we must serialize a valid Topic entity with a non-null ID. // There will be no harm on front-end side. createdTopic = new Topic(); createdTopic.setId(0L); return new ResponseEntity<>( persistentEntityResourceAssembler .toResource(createdTopic), headers, HttpStatus.NO_CONTENT); } } |
RESPONSE_REASON_HEADER is the name of our custom header to be read on the front-end side. Sample name: «server-reason-header».
Please note, there is a pitfall here. If you decide to use as a param HttpEntity
And yet we handle PATCH and corresponding update operation. In some applications for REST requests (I checked two addons for FireFox) it’s impossible to send PATCH requests, so for the sake of testing you can handle in this method PUT requests. Later you can exclude them in production.
So, the code:
@RequestMapping(value = "{topicId}", method = {RequestMethod.PATCH, RequestMethod.PUT}, consumes = APPLICATION_JSON_VALUE, produces = MediaTypes.HAL_JSON_VALUE) @ResponseBody public ResponseEntity<PersistentEntityResource> updateTopic(@PathVariable("topicId") Long topicId, @RequestBody Resource<Topic> topicBody, PersistentEntityResourceAssembler persistentEntityResourceAssembler) { HttpStatus status = HttpStatus.OK; String reason = null; boolean headerNotEmpty = false; Topic changedTopic = topicRepo.findOne(topicId); if (topicId != null) { if (changedTopic == null) { return new ResponseEntity<>( persistentEntityResourceAssembler.toResource(changedTopic), HttpStatus.NO_CONTENT); } // contains values to be updated, passed via JSON Topic newTopic = topicBody.getContent(); if (newTopic != null) { TopicStatus topicStatusOpen = topicStatusRepo.findOne(...); int listSize = 0; if (changedTopic.getTagList() != null) { listSize = changedTopic.getTagList().size(); } if (changedTopic.getTopicStatus().equals(topicStatusOpen) && listSize >= 3 ) { ImmutablePair<Topic, String> result = updateTheTopic(changedTopic); reason = result.getRight(); } else if (...) { // handle another specific case of update } else { // common case of update operation, no need to check possessed resources like list of Tags changedTopic = update(changedTopic, newTopic); } } else { // Not enough data to update, // in response we send unchanged topic. reason = "not enough data to update the topic [" + topicId + "]"; } } // We have a non-empty reason - we have denied to carry out an operation. // In this case we attach a custom header. headerNotEmpty = StringUtils.isNotBlank(reason); if (headerNotEmpty) { status = HttpStatus.NO_CONTENT; // should be treated as error code on the front-end side } // header string is supposed to be blank for successful operations HttpHeaders headers = new HttpHeaders(); if (headerNotEmpty) { headers.add(Constants.RESPONSE_REASON_HEADER, reason); } return new ResponseEntity<>( persistentEntityResourceAssembler.toResource(changedTopic), headers, status); } // I decided to put transactional actions in a separate method so that // spring doesn't get confused with ResponseEntity HttpEntity and other types, // which aren't related to DB. @Transactional private ImmutablePair<Topic, String> saveTheTopic(Topic changedTopic) { String reason = null; // Checks and save operation with topicRepo.save(changedTopic) // Null in reason means save was OK. // Here we can update nested resources atomically. // Say, if a topic is closed, do something with all involved Posts. // Or forbid update of a Topic is some of its Posts is in a specific state, we can report that in our custom header via reason variable. return new ImmutablePair<Topic, String>(changedTopic, reason); } |
Now we have to do spring data’s job and generate a method, which updates an existing entity using properties of an entity passed in JSON via REST. The logic is as follows: we update a property only if the corresponding data from JSON is not null. And it looks like:
@Transactional private Topic update(Topic changedTopic, Topic newTopic) { // newTopic is created based on JSON from request if (newTopic.getUser() != null) { changedTopic.setUser(newTopic.getUser()); } if (newTopic.getTopicStatus() != null) { changedTopic.setTopicStatus(newTopic.getTopicStatus()); } if (newTopic.getPostList() != null) { changedTopic.setPostList(newTopic.getPostList()); } if (newTopic.getTagList() != null) { changedTopic.setTagList(newTopic.getTagList()); } changedTopic = topicRepo.save(changedTopic); return changedTopic; } |
Please note, that if you passed an empty list in TagList or PostList, it will overwrite old list. Only Null i.e. missing field in request’s JSON will leave lists intact in DB.
You will have to add a method as follows (for the interface you implemented):
@Override public PersistentEntityResource process(PersistentEntityResource resource) { Object object = resource.getContent(); if (object instanceof Topic) { Topic topic = (Topic) object; // here you can add links to JSON REST representation of your entity } return resource; } |