[Spring REST API #9] Spring HATEOAS 개념 및 적용

| Spring HATEOAS

HATEOAS 는 Hypermedia As The Engine Of Application State 의 약자로 REST 아키텍처의 한 구성요소이다.
HATEOAS를 통해서 어플리케이션의 상태를 전이할 수 있는 매커니즘을 제공할 수 있다.

참고 URL : https://docs.spring.io/spring-hateoas/docs/current/reference/html/

 

Spring HATEOAS - Reference Documentation

Example 47. Configuring WebTestClient when using Spring Boot @SpringBootTest @AutoConfigureWebTestClient (1) class WebClientBasedTests { @Test void exampleTest(@Autowired WebTestClient.Builder builder, @Autowired HypermediaWebTestClientConfigurer configure

docs.spring.io

 

링크만드는 기능 - 문자열 가지고 만들기, 컨트롤러와 메소드로 만들기

리소스 만드는 기능 - 리소스 : 데이터 + 링크

예를 들어, 송금 어플리케이션이 현재 Home 화면을 나타내는 상태에 있다고 해보자. 이 Home 화면에는 입금,출금,송금 등 다른 화면 혹은 기능, 리소스로 갈 수 있는 링크들이 존재할 것이다. 이 링크를 통해서 다른 페이지로 가는 것을 다른 상태로 전이한다고 보고 이 링크들에 대한 레퍼런스를 서버 측에서 전송한다.
그럼으로서 클라이언트가 명시적으로 링크를 작성하지 않고도 서버 측으로부터 받은 링크의 레퍼런스를 통해 어플리케이션의 상태 및 전이를 표현할 수 있다.
이것이 바로 올바른 REST 아키텍처에서의 HATEOAS 구성법이다.

스프링에서는 스프링 HATEOAS라는 프로젝트를 통해 스프링 사용자들에게 HATEOAS 기능을 쉽게 사용할 수 있도록 제공하고 있다.
이 프로젝트의 중요 기능은 HTTP 응답에 들어갈 유저, 게시판 글, 이벤트 등의 Resource와 다른 상태 혹은 리소스에 접근할 수 있는 링크 레퍼런스인 Links 를 제공하는 것이다.

 



의존성 추가

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

테스트 코드

package org.kyhslam.rest;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.kyhslam.rest.common.TestDescription;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

import java.time.LocalDateTime;

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class EventControllerTests {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    @TestDescription("정상적으로 이벤트를 생성하는 테스트")
    public void createEvent() throws Exception {

        Event event = Event.builder()
                .name("Spring")
                .description("REST API Development")
                .beginEnrollmentDateTime(LocalDateTime.of(2018,11,23,14,21))
                .closeEnrollmentDateTime(LocalDateTime.of(2018,11,23,14,21))
                .beginEventDateTime(LocalDateTime.of(2018,11,25,14,21))
                .endEventDateTime(LocalDateTime.of(2018,11,26,14,21))
                .basePrice(100)
                .maxPrice(200)
                .limitOfEnrollment(100)
                .location("강남역 d2 팩토리")
                .build();

        mockMvc.perform(MockMvcRequestBuilders.post("/api/events/")
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    .accept(MediaTypes.HAL_JSON)
                    .content(objectMapper.writeValueAsString(event)))
                .andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.status().isCreated())
                .andExpect(MockMvcResultMatchers.jsonPath("id").exists())
                .andExpect(MockMvcResultMatchers.header().exists(HttpHeaders.LOCATION))
                .andExpect(MockMvcResultMatchers.header().string(HttpHeaders.CONTENT_TYPE,MediaTypes.HAL_JSON_UTF8_VALUE))
                .andExpect(MockMvcResultMatchers.jsonPath("free").value(false))
                .andExpect(MockMvcResultMatchers.jsonPath("offline").value(true))
                .andExpect(MockMvcResultMatchers.jsonPath("eventStatus").value(EventStatus.DRAFT.name()))
                .andExpect(MockMvcResultMatchers.jsonPath("_links.self").exists())
                .andExpect(MockMvcResultMatchers.jsonPath("_links.query-events").exists())
                .andExpect(MockMvcResultMatchers.jsonPath("_links.update-event").exists())
                ;

    }

}

HTTP 응답 Body에 _links 프로퍼티가 포함된 HATEOAS 정보를 받는 것을 체크하는 테스트 코드이다. _links 에는 리소스 자기 자신을 나타내는 self, events들을 질의할 수 있는 query-events, 그리고 이벤트들을 업데이트 할 수 있는 update-events 링크를 포함할 것이다.

package org.kyhslam.rest;

import org.springframework.hateoas.Link;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.mvc.ControllerLinkBuilder;

public class EventResource extends Resource<Event> {

    public EventResource(Event event, Link... links) {
        super(event, links);
        add(ControllerLinkBuilder.linkTo(EventController.class).slash(event.getId()).withSelfRel());
    }
}

 

  • 스프링 프로젝트에서 HATEOAS 기능을 제공하는 Resource 클래스를 상속받아 위와 같이 Event 클래스의 Resource인 EventResource 클래스를 작성한다. 위 코드를 보면 생성자에서 EventController.class에 매핑되어 있는 URL정보 및 Event 객체 자기 자신을 나타내는 self를 더해 EventResource가 생성되는 것을 알 수 있다.
  • Resource 클래스를 상속받아 쓰는 이유는 Resource 클래스의 필드에 @JsonUnwrapped 어노테이션이 붙어 있어 Event와 같은 여러 프로퍼티가 있는 클래스를 event : {} 같은 감싼 형태가 아닌 아닌 프로퍼티들 그대로 데이터를 추출하여 직렬화 하기 때문이다.
package org.kyhslam.rest;

import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.mvc.ControllerLinkBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.validation.Valid;
import java.net.URI;

@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_UTF8_VALUE)
public class EventController {

    @Autowired
    EventRepository eventRepository;

    @Autowired
    ModelMapper modelMapper;

    @Autowired
    EventValidator eventValidator;

    public EventController(EventRepository eventRepository, ModelMapper modelMapper, EventValidator eventValidator){
        this.eventRepository = eventRepository;
        this.modelMapper = modelMapper;
        this.eventValidator = eventValidator;
    }


    @PostMapping
    public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors){
        if(errors.hasErrors()){
            return ResponseEntity.badRequest().body(errors);
        }

        eventValidator.validate(eventDto, errors);
        if(errors.hasErrors()){
            return ResponseEntity.badRequest().body(errors);
        }

        Event event = modelMapper.map(eventDto, Event.class); // Dto를 event로 변환

        event.update(); // 비즈니스 로직 적용

        Event newEvent = eventRepository.save(event);

        ControllerLinkBuilder selfLinkBuilder = ControllerLinkBuilder.linkTo(EventController.class).slash(newEvent.getId());
        URI createdUri = ControllerLinkBuilder.linkTo(EventController.class).slash(newEvent.getId()).toUri();

        EventResource eventResource = new EventResource(event);
        eventResource.add(ControllerLinkBuilder.linkTo(EventController.class).withRel("query-events"));
        //eventResource.add(selfLinkBuilder.withSelfRel());
        eventResource.add(selfLinkBuilder.withRel("update-event"));
        return ResponseEntity.created(createdUri).body(eventResource);
    }

}

 

결과 화면

- 요청

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /api/events/
       Parameters = {}
          Headers = {Content-Type=[application/json;charset=UTF-8], Accept=[application/hal+json]}
             Body = {"id":null,"name":"Spring","description":"REST API Development","beginEnrollmentDateTime":"2018-11-23T14:21:00","closeEnrollmentDateTime":"2018-11-23T14:21:00","beginEventDateTime":"2018-11-25T14:21:00","endEventDateTime":"2018-11-26T14:21:00","location":"강남역 d2 팩토리","basePrice":100,"maxPrice":200,"limitOfEnrollment":100,"offline":false,"free":false,"eventStatus":null}
    Session Attrs = {}

Handler:
             Type = org.kyhslam.rest.EventController
           Method = public org.springframework.http.ResponseEntity org.kyhslam.rest.EventController.createEvent(org.kyhslam.rest.EventDto,org.springframework.validation.Errors)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

- 응답

MockHttpServletResponse:
           Status = 201
    Error message = null
          Headers = {Location=[http://localhost/api/events/1], Content-Type=[application/hal+json;charset=UTF-8]}
     Content type = application/hal+json;charset=UTF-8
             Body = {"id":1,"name":"Spring","description":"REST API Development",
             "beginEnrollmentDateTime":"2018-11-23T14:21:00","closeEnrollmentDateTime":"2018-11-23T14:21:00",
             "beginEventDateTime":"2018-11-25T14:21:00","endEventDateTime":"2018-11-26T14:21:00","location":"강남역 d2 팩토리","basePrice":100,"maxPrice":200,
             "limitOfEnrollment":100,"offline":true,"free":false,"eventStatus":"DRAFT",
             "_links":{"self":{"href":"http://localhost/api/events/1"},
             "query-events":{"href":"http://localhost/api/events"},
             "update-event":{"href":"http://localhost/api/events/1"}}}
             
    Forwarded URL = null
   Redirected URL = http://localhost/api/events/1
          Cookies = []

- body 부분

{
      "id":1,
      "name":"Spring",
      "description":"REST API Development",
      "beginEnrollmentDateTime":"2018-11-23T14:21:00",
      "closeEnrollmentDateTime":"2018-11-23T14:21:00",
      "beginEventDateTime":"2018-11-25T14:21:00",
      "endEventDateTime":"2018-11-26T14:21:00",
      "location":"강남역 d2 팩토리",
      "basePrice":100,
      "maxPrice":200,
      "limitOfEnrollment":100,
      "offline":true,
      "free":false,
      "eventStatus":"DRAFT",
      "_links":{
        "self":{
        	"href":"http://localhost/api/events/1"
        },
        "query-events":{
        	"href":"http://localhost/api/events"
        },
        "update-event":{
        	"href":"http://localhost/api/events/1"
        }
        }
}

 

댓글

Designed by JB FACTORY