How to override save method of CrudRepository REST wise

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 httpEntity instead of @RequestBody Resource topicBody, it will work only for primitive fields with values like 3, true, «string», [«arr1», «arr2»] as opposed to nested entities set with links like «http://localhost:8080/server/tags/1». So, if you handle such nested entities with HttpEntity, you’ll get an exception No suitable constructor found for type [simple type, class package.Classname]: can not instantiate from JSON object. Resource<> type is far more versatile.

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;
    }
You can leave a response, or trackback from your own site.

Leave a Reply