Building a Hypermedia-Driven RESTful Web Service with Spring HATEOAS

By | December 21, 2016 | 130 Views

1. Introduction.

This note follows up the rest-hateoas tutorial from Spring which describes the general concept about HATEOAS. According to the Richardson Maturity Model, HATEOAS is considered the final level of REST, which explains that the REST resources contain not only data, but links to related resources.

2. Solution with Spring HATEOAS.

From the last example about Paging and sorting with Spring Data JPA and Querydsl, the JSON response consists of data without any link resources. Using Spring Hateoas (Hypermedia as the Engine of Application State) to build up resource representations.

The service will accept HTTP GET requests at:

http://localhost:8081/api/company/search?keyword=a&offset=0&limit=10

A HATEOAS-based response would look like this:

{
  "results": [
    {
      "symbol": "AMZN",
      "companyName": "Amazon.com, Inc.",
      "stockExchangePrefix": "NASDAQ"
    },
    {
      "symbol": "FB",
      "companyName": "Facebook, Inc.",
      "stockExchangePrefix": "NYSE"
    },
   ...
   
  ],
  "offset": 0,
  "total": 12,
  "limit": 10,
  "links": {
    "previous": "http://localhost:8081/api/company/search?keyword=a&offset=0&limit=10",
    "next": "http://localhost:8081/api/company/search?keyword=a&offset=10&limit=10",
    "first": "http://localhost:8081/api/company/search?keyword=a&offset=0&limit=10",
    "last": "http://localhost:8081/api/company/search?keyword=a&offset=10&limit=10"
  }
}

3. Implementation.

3.1 Maven dependency.

Using spring-hateoas dependency and add to POM.xml as below:

<dependencies>
    <dependency>
        <groupId>org.springframework.hateoas</groupId>
        <artifactId>spring-hateoas</artifactId>
        <version>0.20.0.RELEASE</version>
    </dependency>
</dependencies>

3.2 Repository

Implement StockCompanyRepository that extend JpaRepository that find a list of companies.
src/main/java/com/mycompany/app/repository/StockCompanyRepository.java

package com.mycompany.app.repository;

import com.mycompany.app.domain.StockCompanyData;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

/**
 * Define Stock Company Repository.
 */
public interface StockCompanyRepository extends JpaRepository<StockCompanyData, Long> {

    @Query(value = " from StockCompanyData s where 
                 UPPER(s.companyName) LIKE CONCAT('%', UPPER(:companyName), '%') " )
    Page<StockCompanyData> findStockCompanyByParams(
            @Param("companyName") String companyName,
            Pageable pageable);

}

3.2 Service

Modified searchStockCompanyByName method in StockCompanyServiceImpl that responds a company list with paging. Then we use ModelMapper to map HateoasSearchResultDTO
src/main/java/com/mycompany/app/service/StockCompanyServiceImpl.java

package com.mycompany.app.service;

import com.mycompany.app.api.dto.HateoasSearchResultDTO;
import com.mycompany.app.api.dto.StockCompanyDTO;
import com.mycompany.app.api.dto.StockCompanyResultDTO;
import com.mycompany.app.domain.StockCompanyData;
import com.mycompany.app.repository.StockCompanyRepository;
import com.mycompany.app.service.mapper.StockCompanyMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;
import java.util.List;

@Service
@Transactional
public class StockCompanyServiceImpl implements StockCompanyService {

    private static final Logger LOGGER = LoggerFactory.getLogger(StockCompanyServiceImpl.class);
    private final StockCompanyMapper stockCompanyMapper;
    private final StockCompanyRepository stockCompanyRepository;

    @Autowired
    public StockCompanyServiceImpl(final StockCompanyRepository stockCompanyRepository,
                                   final StockCompanyMapper stockCompanyMapper) {
        this.stockCompanyRepository = stockCompanyRepository;
        this.stockCompanyMapper = stockCompanyMapper;
    }

    public HateoasSearchResultDTO searchStockCompanyByName(String keyword, Integer offset, Integer limit) {

        PageRequest pageRequest = new PageRequest(offset/limit, limit);
        Page<StockCompanyData> stockCompanyPage = stockCompanyRepository
                                                  .findStockCompanyByParams(keyword, pageRequest);

        // First, map stockCompanyData found to StockCompanyDTO List.
        List<StockCompanyDTO> stockCompanyDTOs = stockCompanyMapper.
                toStockCompanyDTOList(stockCompanyPage.getContent());

        // Next, create StockCompanyResultDTO that support paging manually.
        final StockCompanyResultDTO results = new StockCompanyResultDTO(stockCompanyDTOs);
        Long total = stockCompanyPage.getTotalElements();
        results.setLimit(stockCompanyPage.getSize());
        results.setOffset(stockCompanyPage.getNumber());
        results.setTotal(total.intValue());

        //Then, Map to HateoasSearchResultDTO to generate links later.
        return stockCompanyMapper.toHateoasSearchResultDTO(results);
    }

}

3.3 Rest

In rest implementation, fillLinksInSearchResult method in SearchResourceAssembler help generate links resources.
src/main/java/com/mycompany/app/rest/StockCompanyResourceImpl.java

package com.mycompany.app.rest;

import com.mycompany.app.api.StockCompanyResource;
import com.mycompany.app.api.dto.HateoasSearchResultDTO;
import com.mycompany.app.service.StockCompanyService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;

/**
 * Created by Khiem on 11/29/2016.
 * Provide resource implementation.
 */
@RestController
@RequestMapping("/api/company")
public class StockCompanyResourceImpl implements StockCompanyResource {

    private static final Logger LOG = LoggerFactory.getLogger(StockCompanyResourceImpl.class);

    @Autowired
    private StockCompanyService stockCompanyService;

    @Autowired
    private SearchResourceAssembler searchResourceAssembler;

    @RequestMapping(value = "/search", method = RequestMethod.GET,
            consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
    @ResponseStatus(HttpStatus.OK)
    @Override
    public HateoasSearchResultDTO findStockCompanyByName(
            @RequestParam(value = "keyword") String keyword,
            @RequestParam(value = "offset") Integer offset,
            @RequestParam(value = "limit") Integer limit) {
        LOG.info("Finding Stock Companies by {}", keyword);

        HateoasSearchResultDTO searchResult = stockCompanyService
                        .searchStockCompanyByName(keyword, offset, limit);
        // Generate links resources. See detail in SearchResourceAssembler 
        searchResourceAssembler.fillLinksInSearchResult(searchResult, keyword, offset, limit);
        return searchResult;
    }
}

3.4 HATEOAS link resources.

Fist of all, we need to implement HateOASUtils that calculates offset page for previous, next, first, last links.
src/main/java/com/mycompany/app/utils/PageOffsetUtils.java

package com.mycompany.app.utils;

public class PageOffsetUtils {

    public static int calculatePreviousPageOffset(Integer offset, Integer limit){
        return Math.max(offset-limit, 0);
    }

    public static int calculateLastPageOffset(Integer limit, Integer total){
        int pageTmp = (int) Math.ceil((double)total / limit);
        return Math.max(pageTmp-1,0)*limit;
    }

    public static int calculateNextPageOffset(Integer offset, Integer limit, Integer total){
        int nextPageOffset = offset + limit;
        if(nextPageOffset < total){
            return nextPageOffset;
        } else {
            return calculateLastPageOffset(limit, total);
        }
    }

    public static int calculateNextPageOffset(Integer offset, Integer limit){
        return offset + limit;
    }
}

Next, org.springframework.hateoas.mvc.ControllerLinkBuilder provides linksTo, methodOn to build the links.

package com.mycompany.app.rest;

import com.mycompany.app.api.dto.HateoasSearchResultDTO;
import org.springframework.stereotype.Component;

import static com.mycompany.app.utils.PageOffsetUtils.calculateLastPageOffset;
import static com.mycompany.app.utils.PageOffsetUtils.calculateNextPageOffset;
import static com.mycompany.app.utils.PageOffsetUtils.calculatePreviousPageOffset;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.methodOn;
/**
 * Created by Khiem on 12/20/2016.
 * Assembler filling previous, next, first, last links
 */
@Component
public class SearchResourceAssembler {

    public void fillLinksInSearchResult(HateoasSearchResultDTO searchResult,
                                     String keyword, Integer offset, Integer limit) {
        Integer total = searchResult.getTotal();
        searchResult.getLinks().setPrevious(linkTo(methodOn(StockCompanyResourceImpl.class)
.findStockCompanyByName(keyword, calculatePreviousPageOffset(offset, limit), limit)
        ).toUri().toString());

        searchResult.getLinks().setNext(linkTo(methodOn(StockCompanyResourceImpl.class)
.findStockCompanyByName(keyword , calculateNextPageOffset(offset, limit, total), limit)
        ).toUri().toString());

        searchResult.getLinks().setFirst(linkTo(methodOn(StockCompanyResourceImpl.class)
.findStockCompanyByName(keyword, 0, limit)
        ).toUri().toString());

        searchResult.getLinks().setLast(linkTo(methodOn(StockCompanyResourceImpl.class)
.findStockCompanyByName(keyword, calculateLastPageOffset(limit, total), limit)
        ).toUri().toString());
    }
}

References:

  • Richardson Maturity Model, Retrieved from http://martinfowler.com/articles/richardsonMaturityModel.html on Dec 21, 2016.
  • Spring HATEOAS, Retrieved from http://projects.spring.io/spring-hateoas/#quick-start on Dec 21, 2016.

Leave a Reply

Your email address will not be published. Required fields are marked *