HAL 與 RestTemplate


在〈聊聊 Spring HATEOAS〉中大致談過,如何使用 Spring HATEOAS,讓應用程式的 REST 模型可以支援 HATEOAS,現在的問題是,若要使用 RestTemplate 來處理傳回的 HAL 該怎麼做?

就結論而言,必須為 RestTemplate 設置 HAL 轉換器,而這個轉換器的建立方式是:

private MappingJackson2HttpMessageConverter getHalMessageConverter() {
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    objectMapper.registerModule(new Jackson2HalModule());
    MappingJackson2HttpMessageConverter halConverter = new TypeConstrainedMappingJackson2HttpMessageConverter(
            ResourceSupport.class);
    halConverter.setSupportedMediaTypes(Arrays.asList(MediaTypes.HAL_JSON));
    halConverter.setObjectMapper(objectMapper);
    return halConverter;
}

有點麻煩對吧!稍後會談到一個有點 HACK 的方式,總之,有了這個轉換器後,可以在建立 RestTemplate 時設定給 RestTemplate 實例:

public RestTemplate restTemplate() {
    RestTemplate restTemplate = new RestTemplate();
    List<HttpMessageConverter<?>> existingConverters = restTemplate.getMessageConverters();
    List<HttpMessageConverter<?>> newConverters = new ArrayList<>();
    newConverters.add(getHalMessageConverter());
    newConverters.addAll(existingConverters);
    restTemplate.setMessageConverters(newConverters);
    return restTemplate;
}

有了這個 RestTemplate 實例,對於底下這類 HAL JSON 回應:

{
    "text": "msg1",
    "_links": {
        "self": {
            "href": "http://localhost:8080/messages/1"
        }
    }
}

可以如下將之轉為 ResponseEntity<Resource<Message>>

@Test
public void show() {
    RequestEntity<Void> request = RequestEntity
            .get(URI.create(String.format("http://localhost:8080/messages/%s", "1")))
            .build();

     ResponseEntity<Resource<Message>> response = 
             restTemplate.exchange(request, new ParameterizedTypeReference<Resource<Message>>(){});

    assertNotNull(response.getBody().getContent().getText());
}

回應的本體會是 Resource<Message>,因此可以透過 getContent 取得 Message 實例。至於底下之類的 HAL JSON 格式:

{
    "_embedded": {
        "messageList": [
            {
                "text": "msg1",
                "_links": {
                    "self": {
                        "href": "http://localhost:8080/messages/1"
                    }
                }
            },
            {
                "text": "msg2",
                "_links": {
                    "self": {
                        "href": "http://localhost:8080/messages/2"
                    }
                }
            }
        ]
    },
    "_links": {
        "self": {
            "href": "http://localhost:8080/messages"
        }
    }
}

可以透過底下方式取得:

@Test
public void index() {
    RequestEntity<Void> request = RequestEntity
            .get(URI.create("http://localhost:8080/messages/"))
            .build();

    ResponseEntity<Resources<Message>> response = 
            restTemplate.exchange(request, new TypeReferences.ResourcesType<Message>() {});

    assertTrue(response.getBody().getContent().size() > 0);
}

有興趣看完整範例專案的話,可以在 HATEOAS 取得。

那麼,怎麼簡單地取得 HAL 轉換器呢?既然 Spring Data Rest 透過 Spring HATEOAS 來轉換出 HAL JSON,那是不是意謂著,它內部就有 HAL 轉換器了?是的!你可以在專案的 build.gradle 中包含 Spring Data Rest:

implementation('org.springframework.boot:spring-boot-starter-data-rest')

這麼一來,Spring Boot 就會生成 HAL 轉換器,而 Bean 的名稱為 "halJacksonHttpMessageConverter",例如,在 XYZ 這個範例專案中,就以 @Qualifier 指定 "halJacksonHttpMessageConverter",直接取得了這個轉換器,基於 RestTemplate 來請求 @RepositoryRestResource 中範例專案產生的 HAL JSON,建立了一個簡單的訊息查看頁面:

package cc.openhome;

...略

@SpringBootApplication
@Controller
public class XyzApplication {

    public static void main(String[] args) {
        SpringApplication.run(XyzApplication.class, args);
    }

    @Autowired
    private RestTemplate restTemplate;

    @GetMapping("messages/{id}")
    public String user(@PathVariable("id") String id, Model model) {
        RequestEntity<Void> request = RequestEntity
                .get(URI.create(String.format("http://localhost:8080/messages/%s", id)))
                .build();

         ResponseEntity<Resource<Message>> response = 
                 restTemplate.exchange(request, new ParameterizedTypeReference<Resource<Message>>(){});

        model.addAttribute("title", String.format("第 %s 筆訊息", id));
        model.addAttribute("messages", Arrays.asList(response.getBody().getContent()));
        return "show";
    }

    @GetMapping("{username}/messages")
    public String userMessages(@PathVariable("username") String username, Model model) {
        RequestEntity<Void> request = RequestEntity
                .get(URI.create(String.format("http://localhost:8080/messages/search/messagesBy?username=%s", username)))
                .build();

        ResponseEntity<Resources<Message>> response = 
                restTemplate.exchange(request, new TypeReferences.ResourcesType<Message>() {});

        model.addAttribute("title", String.format("%s 的訊息", username));
        model.addAttribute("messages", new ArrayList<>(response.getBody().getContent()));
        return "show";
    }


    @Bean
    public RestTemplate restTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        List<HttpMessageConverter<?>> existingConverters = restTemplate.getMessageConverters();
        List<HttpMessageConverter<?>> newConverters = new ArrayList<>();
        newConverters.add(getHalMessageConverter());
        newConverters.addAll(existingConverters);
        restTemplate.setMessageConverters(newConverters);
        return restTemplate;
    }

    @Autowired
    @Qualifier("halJacksonHttpMessageConverter")
    private TypeConstrainedMappingJackson2HttpMessageConverter halConverter;

    public MappingJackson2HttpMessageConverter getHalMessageConverter() {
        return halConverter;
    }
}