REST API(8) HATEOAS 적용
Spring HATEOAS 소개
Spring HATEOAS 란 HATEOAS 를 만족하는 REST representation을 제공하는 API를 만들 때 편리하게 사용할 수 있는 툴을 제공해 주는 라이브러리이다. HATEOAS를 사용하면 클라이언트는 애플리케이션 서버가 하이퍼미디어(Link)를 통해 동적으로 정보를 제공하는 네트워크 애플리케이션과 상호 작용한다.
HATEOAS의 예시와 설명
예를 이용하여 이해해보자.
1
2
GET /accounts/12345 HTTP/1.1
Host: bank.example.com
이 GET
요청은 12345
라는 계정 리소스를 가져와서 JSON표현으로 정보를 요청한다.
이에 대한 응답은 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HTTP/1.1 200 OK
{
"account": {
"account_number": 12345,
"balance": {
"currency": "usd",
"value": 100.00
},
"links": {
"deposits": "/accounts/12345/deposits",
"withdrawals": "/accounts/12345/withdrawals",
"transfers": "/accounts/12345/transfers",
"close-requests": "/accounts/12345/close-requests"
}
}
}
이 응답에는 deposits(입금)
, withdrawals(출금)
, transfers(이체)
, close-requests(요청 닫기/계좌 닫기)
와 같은 후속 링크들을 포함하고 있고, 이는 애플리케이션과 클라이언트 간에 어떠한 상호작용을 할 수 있는지 릴레이션을 나타낸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
HTTP / 1.1 200 OK
{
"account": {
"account_number": 12345,
"balance": {
"currency": "usd",
"value": -25.00
},
"links": {
"deposits": "/accounts /12345/예금"
}
}
}
위 정보와 같이, 계좌에 있는 금액이 마이너스(-)인 경우에는 deposits(입금)
만 가능하도록 구현해야한다.
즉, HATEOAS 는 애플리케이션 상태의 변화에 따라 링크의 정보가 바뀌어야 한다.
Spring HATEOAS의 기능
Spring HATEOAS 가 제공하는 가장 중요한 두 가지 기능은 다음과 같다.
- 링크 만드는 기능
- 문자열 가지고 만들기
- 컨트롤러와 메소드로 만들기
- 리소스 만드는 기능
- 리소스 = 데이터(원래 전달해 주고자 하는 응답 본문) + 링크
Event 생성 API에 들어가야하는 링크 정보
크게 HREF
와 REL
정보가 들어가야 하는데, 자세히 살펴보면 다음과 같다.
HREF
: URI 나 URL 설정REL
: 현재 리소스와의 관계- self : 자기 자신에 대한 URL
- profile : 응답 본문에 대한 문서(docs)로 링크
- update-event : 이벤트 수정 링크
- query-events : 이벤트 조회 링크
Event 생성 API에 Spring HATEOAS 적용
우리가 만든 Event 생성 API에 Spring HATEOAS를 적용해 보자.
구현 Package 및 Class
EventControllerTests
우리는 이벤트가 정상적으로 생성 되었을 때 링크 정보를 받을 수 있어야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
//import 생략
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class EventControllerTests {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Test
@TestDescription("정상적으로 이벤트를 생성하는 테스트")
public void createEvent() throws Exception {
EventDto event = EventDto.builder()
.name("Spring")
.description("REST API Development With Spring")
.beginEnrollmentDateTime(LocalDateTime.of(2024, 02, 19, 14, 21))
.closeEnrollmentDateTime(LocalDateTime.of(2024, 02, 20, 14, 21))
.beginEventDateTime(LocalDateTime.of(2024, 02, 21, 14, 21))
.endEventDateTime(LocalDateTime.of(2024, 02, 22, 14, 21))
.basePrice(100)
.maxPrice(200)
.limitOfEnrollment(100)
.location("강남역 D2 스타트업 팩토리")
.build();
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaTypes.HAL_JSON)
.content(objectMapper.writeValueAsString(event))
)
.andDo(print())
.andExpect(status().isCreated())
.andExpect(jsonPath("id").exists())
.andExpect(header().exists(HttpHeaders.LOCATION))
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_UTF8_VALUE))
.andExpect(jsonPath("free").value(false))
.andExpect(jsonPath("offline").value(true))
.andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.name()))
.andExpect(jsonPath("_links.self").exists()) // self link
.andExpect(jsonPath("_links.query-events").exists()) // 이벤트 조회 링크
.andExpect(jsonPath("_links.update-event").exists()) // 이벤트 수정 링크
;
}
// 이 외 테스트 코드 생략
}
EventResource 클래스 생성 (1) ResourceSupport 상속
원래 전달해주고자 하는 요청한 객체에 대한 응답, 즉 데이터에 하이퍼미디어(link)를 추가해주기 위해서는 ResourceSupport
를 상속받는 클래스가 필요하다. 이를 EventResource
라는 이름의 클래스로 만들어주겠다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package me.hantomas.restapi.events;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import org.springframework.hateoas.ResourceSupport;
public class EventResource extends ResourceSupport {
private Event event;
public EventResource(Event event){
this.event = event;
}
public Event getEvent() {
return event;
}
}
EventController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package me.hantomas.restapi.events;
import org.modelmapper.ModelMapper;
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;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_UTF8_VALUE)
public class EventController {
private final EventRepository eventRepository;
private final ModelMapper modelMapper;
private final 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);
event.update();
Event newEvent = this.eventRepository.save(event);
ControllerLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(newEvent.getId()); // (1)
URI createdUri = selfLinkBuilder.toUri();
EventResource eventResource = new EventResource(newEvent); // (2)
eventResource.add(linkTo(EventController.class).withRel("query-events")); // "query-events"
eventResource.add(selfLinkBuilder.withSelfRel()); //"self"
eventResource.add(selfLinkBuilder.withRel("update-event")); // "update-event"
return ResponseEntity.created(createdUri).body(eventResource);
}
}
(1)
: 공통으로 쓰일 코드를 리팩토링 해준다. “/api/events/{id}”(2)
:EventResource
를 호출하고newEvent
객체를 담아준다."query-events"
,"self"
,"update-event"
관계(REL)의 링크들을 추가해준다.
결과(실패)
링크들이 잘 추가되어서 출력되는 것을 확인해 볼 수 있지만, id
값을 찾지 못했다는 오류가 발생한다.
그 이유는 이렇다.
우리는 Controller
에서 EventResource
의 객체를 리턴했고, 이는 ObjectMapper
가 BeanSerializer
를 이용해 Serialization한다. BeanSerializer
는 EventResource
의 Event
객체인 event
를 기본 필드로 사용한다. 이 Event
객체는 id
, name
, description
, … 등의 필드를 포함하고 있는 객체이기 때문에, 위와 같이 event
라는 이름 아래에 그 필드들이 표시되고, 그래서 id 값을 찾지 못한다는 오류가 발생하게 되는 것이다.
EventResource 수정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package me.hantomas.restapi.events;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import org.springframework.hateoas.ResourceSupport;
public class EventResource extends ResourceSupport {
@JsonUnwrapped
private Event event;
public EventResource(Event event){
this.event = event;
}
public Event getEvent() {
return event;
}
}
이 해결법은 간단하다. event
대신에 Event
에 있는 모든 필드들을 직접 선언하여 주입하고 getter로 꺼내주면 된다. 하지만 그렇게 되면 코드는 굉장히 복잡하고 길어질 것이다.
다른 간단한 방법은 @JsonUnwrapped
어노테이션만 추가해 주면 된다. 그러면 event
로 Wrapping 되어있는 필드들을 꺼내준다.
결과 (성공)
event
가 Unwrapped 된 것을 확인 할 수 있다.
EventResource 클래스 (2) Resource 상속
ResourceSupport
라는 클래스 하위의 Resource<T>
클래스를 상속받는 방법이다.
Resource<T>
는 getter
에 @JsonUnwrapped
가 붙어 있다. 따라서 어노테이션을 통해 getter메소드를 구현하지 않아도 되고, 명시적으로 Unwrapped하지 않아도 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package me.hantomas.restapi.events;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Resource;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
public class EventResource extends Resource<Event> {
public EventResource(Event event, Link... links) {
super(event, links);
//add(new Link("http://localhost:8080/api/events/"+ event.getId())); // TypeSafe하지 않다.
add(linkTo(EventController.class).slash(event.getId()).withSelfRel());
}
}
"self"
링크의 경우 리소스마다 있기 때문에, 위 처럼EventResource
에 추가해 주는 것이 좋다.
결과(성공)
추가
HATEOAS v1.0
이후 부터는 사용하는 클래스 이름이 변경되었다고 한다.
변경 전 | 변경 후 |
---|---|
ResourceSupport | RepresentationModel |
Resource | EntityModel |
Resources | CollectionModel |
PagedResources | PagedModel |
ResourceAssembler | RepresentationModelAssembler |