[Spring REST API #9] Spring HATEOAS 개념 및 적용
- 📚 Spring/Spring REST
- 2020. 8. 4. 01:49
| Spring HATEOAS
HATEOAS 는 Hypermedia As The Engine Of Application State 의 약자로 REST 아키텍처의 한 구성요소이다.
이 HATEOAS를 통해서 어플리케이션의 상태를 전이할 수 있는 매커니즘을 제공할 수 있다.
참고 URL : https://docs.spring.io/spring-hateoas/docs/current/reference/html/
링크만드는 기능 - 문자열 가지고 만들기, 컨트롤러와 메소드로 만들기
리소스 만드는 기능 - 리소스 : 데이터 + 링크
예를 들어, 송금 어플리케이션이 현재 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"
}
}
}
'📚 Spring > Spring REST' 카테고리의 다른 글
RestAPI Document를 위한 Swagger (0) | 2021.06.29 |
---|---|
[Spring REST API #10] Spring REST Docs 개념 및 적용 (0) | 2020.08.05 |
[Spring REST API #8] REST API 비즈니스 로직 적용 및 JunitParam을 이용한 테스트 (0) | 2020.08.03 |
[Spring REST API #7] Bad Request 에 대한 본문 메세지 (0) | 2020.07.31 |
[Spring REST API #6] Bad Request (도메인 Validator를 통한 처리) (0) | 2020.07.31 |