Spring HATEOAS
HATEOAS란?
Hypermedia As The Engine Of Application State의 약자이며 하이퍼미디어를 통해 정보를 동적으로 제공할 수 있습니다
API에서 리소스에 대해 어떤 행동을 할 수 있는지에 대해 하이퍼미디어(=링크)를 제공하여 다른 상태로 전이가 가능합니다
REST API의 구성요소 중 하나인데 HATEOAS를 만족해야 진정한 REST API라고 할 수 있습니다
- Uniform Interface of REST API
- 필요한 이유 : 서버와 클라이언트가 독립적으로 진화하면 서버의 기능이 변경되어도 클라이언트에서 변경을 할 필요가 없다
- Self-Descriptive : 기능이 변경되어도 메시지는 언제나 해석이 가능해야 한다
- HATEOAS : 어떤 상태에서 전이 가능한 링크를 제공해야 한다
Resource
Resource는 데이터와 링크로 구성되며 링크는 rel과 href로 구성됩니다
Resource
= Data
+ Link
- Data : 서버에서 제공하는 데이터
- Link : 다른 상태로 전이 가능한 링크정보
Link
= rel
+ href
- rel : 링크 이름
- href : 링크 URL
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-hateoas'
}
단일 Resource
-
EntityModel
사용 -
EntityModel.of(데이터)
: 해당 데이터를 Resource에 담아 return하는 스태틱 메서드 -
add(링크)
: Resource에 링크를 추가하는 메서드 -
linkTo(controller).slash(문자)
: 해당 controller의 method URI를controller/문자
형태로 URI 반환 -
methodOn(controller)
: Method 이름을 사용하여 해당 method에 설정된 URI를 반환한다
@RequestMapping("/api/event")
@RequiredArgsConstructor
@RestController
public class EventController {
private final EventService eventService;
@GetMapping("/{id}")
public ResponseEntity getEvent(@PathVariable Long id) {
Event event = eventService.getEvent(id);
EntityModel<Event> resource = EntityModel.of(event);
resource.add(linkTo(methodOn(this.getClass()).getEvent(id)).withSelfRel());
resource.add(linkTo(UserController.class).slash("mypage").withRel("My Page"));
resource.add(Link.of("/docs/index.html#event").withRel("Event Docs")); // Self-Descriptive를 위한 Docs링크
return ResponseEntity.ok(resource);
}
}
{
"id": 1,
"title": "test title",
"content": "test content",
"_links": {
"self": {
"href": "http://localhost:8080/api/event/1"
},
"My Page": {
"href": "http://localhost:8080/user/mypage"
},
"Event Docs": {
"href": "/docs/index.html#event"
}
}
}
컬렉션 Resource
-
CollectionNodel
사용 -
CollectionModel.of(컬렉션, 링크..)
: 해당 컬렉션 데이터를 Resource에 담아 return하는 스태틱 메서드
@RequestMapping("/api/event")
@RequiredArgsConstructor
@RestController
public class EventController {
private final EventService eventService;
private final EventConverter eventConverter;
@GetMapping
public ResponseEntity getEventList() {
List<EntityModel<Event>> eventList = eventService.getEventList()
.stream().map(event -> eventConverter.toModel(event)).collect(Collectors.toList());
CollectionModel<EntityModel<Event>> resource = CollectionModel.of(eventList,
linkTo(methodOn(this.getClass()).getEventList()).withSelfRel());
resource.add(Link.of("/docs/index.html#event-list").withRel("Event List Docs")); // Self-Descriptive를 위한 Docs링크
return ResponseEntity.ok(resource);
}
}
- 컬렉션에 들어있는 데이터를 resource로 변환하여 EntityModel로 만드는 과정을 캡슐화할 수 있다
RepresentationModelAssembler<from, to>
@Component
public class EventConverter implements RepresentationModelAssembler<Event, EntityModel<Event>> {
@Override
public EntityModel<Event> toModel(Event event) {
return EntityModel.of(event,
linkTo(methodOn(EventController.class).getEvent(event.getId())).withSelfRel());
}
}
{
"_embedded": {
"eventList": [
{
"id": 1,
"title": "test title",
"content": "test content",
"_links": {
"self": {
"href": "http://localhost:8080/api/event/1"
}
}
},
{
"id": 2,
"title": "title",
"content": "content",
"_links": {
"self": {
"href": "http://localhost:8080/api/event/2"
}
}
}
]
},
"_links": {
"self": {
"href": "http://localhost:8080/support/event"
},
"Event List Docs": {
"href": "/docs/index.html#event-list"
}
}
}
Test
@AutoConfigureRestDocs(uriScheme = "https", uriHost = "docs.api.com")
@WebMvcTest(EventController.class)
@Import(RestDocsConfig.class)
public class EventControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@MockBean
private EventService eventService;
@Test
@DisplayName("이벤트 1건 조회")
public void getEvent() throw Exception {
// given
Event event = Event.builder()
.id(1L)
.title("test")
.content("test content")
.build();
given(eventService.getEvent(any())).willReturn(event);
// when & then
mvc.perform(get("/api/event/{id}", event.getId())
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaTypes.HAL_JSON))
// .content(objectMapper.writeValueAsString(id))) // Request Body 필요없음
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE))
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.title").exists())
.andExpect(jsonPath("_links.self").exists())
.andDo(print())
verify(eventService).getEvent(any());
}
@Test
@DisplayName("이벤트 List 조회")
void getEventList() throws Exception {
// given
List<EventDTO> eventList = new ArrayList<>();
for (long i=1L; i < 3L; i++) {
EventDTO event = EventDTO.builder()
.id(i)
.title("event title " + i)
.content("event content " + i)
.build();
eventList.add(event);
}
given(eventService.getEventList()).willReturn(eventList);
// when & then
mvc.perform(get("/api/event")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaTypes.HAL_JSON))
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE))
.andExpect(jsonPath("$..title").exists())
.andExpect(jsonPath("$..content").exists())
.andExpect(jsonPath("_links.self").exists())
.andDo(print());
verify(eventService).getEventList();
}
}