BackEnd/Spring

[Spring] HATEOAS - REST API & HATEOAS

JJangGu 2022. 5. 1. 16:34

이번에는 REST API 와 HATEOAS 에 대한 이야기를 해보겠습니다.

인프런의 백기선님의 강의 내용을 바탕으로 학습한 내용입니다.

 

REST API

REpresentational State Transfer Application Programming Interface 의 약자입니다.
REST 는 인터넷 상에서 시스템 간의 상호 운용성을 제공하는 방법중 하나인데, 위키에서 정의한 것을 보면, 월드 와이드 웹과 같은 분산 하이퍼 미디어 시스템을 위한 소프트웨어 아키텍처의 한 형식이라고 소개 하고 있습니다. 
REST API 라고 하는 것은 이 REST 아키텍처 스타일을 따르는 API 입니다.

https://ko.wikipedia.org/wiki/REST

 

REST - 위키백과, 우리 모두의 백과사전

대한민국의 힙합 음악가에 대해서는 R-EST 문서를 참고하십시오. REST(Representational State Transfer)는 월드 와이드 웹과 같은 분산 하이퍼미디어 시스템을 위한 소프트웨어 아키텍처의 한 형식이다. 이

ko.wikipedia.org

 

지금 사용되는 여러 REST API 가 있는데, 검색을 해보고 공부를 하다보면 여러 곳에서 기존의 API 들은 REST 를 만족하고 있지 않는다고 말합니다. 특히 두가지 문제가 있는데요, 이 문제에 대해 살펴보겠습니다.

 

* Self-descriptive message (자기 서술적 메시지)

: 각 메시지는 자신을 어떻게 처리해야 하는지에 대한 충분한 정보를 포함해야 한다.

 

이 자기 서술적인 메시지를 통해서 메시지는 스스로 메시지에 대한 설명이 가능해야 하고, 메세지가 어떤 미디어 타입인지 어떤 파서를 이용해야 하는지에 대한 정보를 포함해야 합니다. 메세지가 변해도, 클라이언트는 메시지를 보고 해석이 가능해야 하죠. 이를 통해서 확장 가능한 커뮤니케이션이 보장되어야 합니다. 

 

* Hypermedia as the engine of application state (HATEOAS)

: 클라이언트는 서버에서 제공하는 링크를 동적으로 사용하여 필요한 모든 사용가능한 리소스를 검색할 수 있어야 합니다. 엑세스가 진행되면 서버는 현재 사용 가능한 다른 리소스에 대한 하이퍼링크가 포함된 응답을 해야합니다.

 

하이퍼미디어(링크)를 통해 애플리케이션의 상태 변화가 가능해야 합니다. 그리고 버젼을 바꾸거나 할 필요없이 링크가 동적으로 생성됩니다.

 

위의 두가지가 많은 분들이 REST API 의 문제점으로 지적하는 부분입니다. 문제점이라기보다는 지켜지지 않는 부분이죠. 개발을 하다가 외부 API 들을 많이 사용하실거라고 생각하는데, API 의 스팩들을 다시 살펴보아도 저 스팩들을 제대로 지키고 있는 API 를 찾아보기 힘듭니다.

 

그러면 이 부분들을 어떻게 해결하는지 살펴보겠습니다. 많은 방법들이 있겠지만 이번 포스트에서는 백기선님이 인프런에서 교육하실 때 사용하는 방법에 대해서 소개를 해보겠습니다. 

 

먼저, Self-descriptive message 의 해결방법입니다.

- 방법 1 : 미디어 타입을 정의하고 IANA에 등록, 그 미디어 타입을 리소스 리턴할 때 Content-type 으로 사용한다.

- 방법 2 : profile 링크 헤더를 추가한다. 

 

위의 방법 중 2번째를 추천하는데, 그 이유는 브라우저들이 아직 스팩을 지원하지 않는 경우가 있다고 합니다. 

그래서 대안으로 HAL 의 링크 데이터에 profile 링크를 추가하는 것 입니다. 

 

* HAL : JSON 또는 XML 코드 내에서 외부 리소스에 대한 링크와 같은 하이퍼 미디어를 정의하기 위한 Internet Draft

https://en.wikipedia.org/wiki/Hypertext_Application_Language

 

Hypertext Application Language - Wikipedia

Standard convention for defining hypermedia such as links to external resources within JSON or XML code Hypertext Application Language (HAL) is an Internet Draft (a "work in progress") standard convention for defining hypermedia such as links to external r

en.wikipedia.org

 

다음으로, HATEOAS 에 대한 해결방법입니다.

- 방법 1 : 데이터에 링크 제공. 어떻게 링크를 정의할 것인가 ? => HAL

- 방법 2 : 링크 헤더나 Location을 제공

 

그러면 위의 해결방법들을 보았을 때, HAL 의 링크데이터를 활용하면 두가지를 해결할 방법이 보입니다.

 

이제, 예제를 통해서 보겠습니다.

 

강의를 들으며 작성한 REST Docs 의 내용입니다. 위의 내용들이 만족되어 있는 모습을 확인할 수 있습니다.

 

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

import java.util.Arrays;

import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.Link;

public class EventResource extends EntityModel<Event> {

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

Event 엔티티에 대한 응답을 줄 때, 이를 EntityModel 로 감싸서 링크를 추가할 수 있는 EventResource 로 리턴을 해주는 클래스입니다. (강의에서는 Resource 를 extends 하지만 현재의 버전에서는 EntityModel 로 바뀌었음)

 

EventController 소스를 살펴보면,

 

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

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

    Event event = modelMapper.map(eventDto, Event.class);
    event.update();

    Event newEvent = eventRepository.save(event);
    WebMvcLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(newEvent.getId());
    URI createdUri = selfLinkBuilder.toUri();

    EventResource eventResource = new EventResource(event);
    eventResource.add(linkTo(EventController.class).withRel("query-events"));
    eventResource.add(selfLinkBuilder.withRel("update-event"));
    eventResource.add(Link.of("/docs/index.html#resources-events-created").withRel("profile"));


    return ResponseEntity.created(createdUri).body(eventResource);
}

 

이와 같이, EventResource 에 원하는 링크들을 추가하여 REST API 를 만족시킨 모습입니다. 

 

테스트 코드를 살펴보며 검증하는 것을 끝으로 마치겠습니다.

 

@DisplayName("성공하는 Event 생성")
@Test
void createEvent() throws Exception {
    EventDto eventDto = EventDto.builder()
        .name("Test")
        .description("test description")
        .beginEnrollmentDateTime(LocalDateTime.of(2022, 4, 26, 0, 0))
        .closeEnrollmentDateTime(LocalDateTime.of(2022, 4, 30, 0, 0))
        .beginEventDateTime(LocalDateTime.of(2022, 4, 26, 0, 0))
        .endEventDateTime(LocalDateTime.of(2022, 4, 30, 0, 0))
        .basePrice(100)
        .maxPrice(200)
        .limitOfEnrollment(100)
        .location("미사")
        .build();

    mockMvc.perform(post("/api/events/")
        .contentType(MediaType.APPLICATION_JSON)
        .accept(MediaTypes.HAL_JSON)
        .content(objectMapper.writeValueAsString(eventDto))
    )
        .andDo(print())
        .andExpect(status().isCreated())
        .andExpect(jsonPath("id").exists())
        .andExpect(header().exists(HttpHeaders.LOCATION))
        .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE))
        .andExpect(jsonPath("id").value(Matchers.not(1L)))
        .andExpect(jsonPath("free").value(false))
        .andExpect(jsonPath("eventStatus").value(Matchers.not(EventStatus.DRAFT)))
        .andExpect(jsonPath("_links.self").exists())
        .andExpect(jsonPath("_links.query-events").exists())
        .andExpect(jsonPath("_links.update-event").exists())
        .andExpect(jsonPath("_links.profile").exists())
        .andDo(document("create-event",
            links(
                linkWithRel("self").description("link to self"),
                linkWithRel("query-events").description("link to query events"),
                linkWithRel("update-event").description("link to update an event"),
                linkWithRel("profile").description("link to profile")
            ),
            requestHeaders(
                headerWithName(HttpHeaders.ACCEPT).description("accept header"),
                headerWithName(HttpHeaders.CONTENT_TYPE).description("content type header")
            ),
            requestFields(
                fieldWithPath("name").description("Name of new event"),
                fieldWithPath("description").description("Description of new event"),
                fieldWithPath("beginEnrollmentDateTime").description("BeginEnrollmentDateTime of new event"),
                fieldWithPath("closeEnrollmentDateTime").description("CloseEnrollmentDateTime of new event"),
                fieldWithPath("beginEventDateTime").description("BeginEventDateTime of new event"),
                fieldWithPath("endEventDateTime").description("EndEventDateTime of new event"),
                fieldWithPath("location").description("Location of new event"),
                fieldWithPath("basePrice").description("Base Price of new event"),
                fieldWithPath("maxPrice").description("Max Price of new event"),
                fieldWithPath("limitOfEnrollment").description("Limit of Enrollment of new event")
            ),
            responseHeaders(
                headerWithName(HttpHeaders.LOCATION).description("location of response header"),
                headerWithName(HttpHeaders.CONTENT_TYPE).description("content type of response header")
            ),
            responseFields(
                fieldWithPath("id").description("identifier of new event"),
                fieldWithPath("name").description("Name of new event"),
                fieldWithPath("description").description("Description of new event"),
                fieldWithPath("beginEnrollmentDateTime").description("BeginEnrollmentDateTime of new event"),
                fieldWithPath("closeEnrollmentDateTime").description("CloseEnrollmentDateTime of new event"),
                fieldWithPath("beginEventDateTime").description("BeginEventDateTime of new event"),
                fieldWithPath("endEventDateTime").description("EndEventDateTime of new event"),
                fieldWithPath("location").description("Location of new event"),
                fieldWithPath("basePrice").description("Base Price of new event"),
                fieldWithPath("maxPrice").description("Max Price of new event"),
                fieldWithPath("limitOfEnrollment").description("Limit of Enrollment of new event"),
                fieldWithPath("free").description("it tells if this event is free or not"),
                fieldWithPath("offline").description("it tells if this event is offline or not"),
                fieldWithPath("eventStatus").description("event status"),
                fieldWithPath("_links.self.href").description("link to self"),
                fieldWithPath("_links.query-events.href").description("link to query events"),
                fieldWithPath("_links.update-event.href").description("link to update event"),
                fieldWithPath("_links.profile.href").description("link to profile")
            )
        ));
}