[Spring] hateoas

노유성·2023년 7월 24일
0
post-thumbnail

들어가며

RESTful API를 만들기 위해서 지켜야하는 것 중 하나는 바로 hateoas이다. hateoas를 한 줄로 정리하면 다음과 같다.

Hypermedia (링크)를 통해서 애플리케이션의 상태 전이가 가능해야 한다.

예를 들어서

GET https://localhost:8080/content

라는 get요청이 있다고 해보자. 게시글을 조회하는 요청인데 그러면 사용자는 다음의 행동을 할 수 있다.

  • 게시글 수정(게시글 작성자인 경우에만)
  • 댓글 달기
  • 게시글 저장

위와 같은 행동들을 하기 위한 URI를 맨 처음에 /content로 GET 요청을 했을 때 payload에 담겨있어야한다. 그게 바로 hateoas다. 이걸 어떻게 하면 구현할 수 있을까? 바로 Hypermedia 통해서 넣는다.

자 그러면 이제 response에는 다음 내용이 들어갈 수 있따.

{
  "data": {
    "id": 1000,
    "name": "게시글 1",
    "content": "HAL JSON을 이용한 예시 JSON",
    "self": "http://localhost:8080/api/article/1000", // 현재 api 주소
    "profile": "http://localhost:8080/docs#query-article", // 해당 api의 문서
    "next": "http://localhost:8080/api/article/1001", // 다음 article을 조회하는 URI
    "comment": "http://localhost:8080/api/article/comment", // article의 댓글 달기
    "save": "http://localhost:8080/api/feed/article/1000", // article을 내 피드로 저장
  },
}

하지만 이 만으로는 완벽하지 않다. 왜 그러냐면 사용자가 요구한 data와 전이할 URI가 같은 value로 묶여있기 떄문이다. 직관적이지 않다.

HAL

Hypertext Application Language의 약자로 JSON, XML 코드 내의 외부 리소스에 대한 링크를 추가하기 위한 데이터 타입이다.

HAL은

  • application/hal+sonm
  • application/hal+xml

두 가지 타입을 갖는다.

application/hal+json에는 두 가지 필드가 있다. 하나는 리소스 필드, 하나는 링크필드이다.

{
  "data": { // HAL JSON의 리소스 필드
    "id": 1000,
    "name": "게시글 1",
    "content": "HAL JSON을 이용한 예시 JSON"
  },
  "_links": { // HAL JSON의 링크 필드
    "self": {
      "href": "http://localhost:8080/api/article/1000" // 현재 api 주소
    },
    "profile": {
      "href": "http://localhost:8080/docs#query-article" // 해당 api의 문서
    },
    "next": {
      "href": "http://localhost:8080/api/article/1001" // article 의 다음 api 주소
    },
    "prev": {
      "href": "http://localhost:8080/api/article/999" // article의 이전 api 주소
    }
  }
}

이렇게 된다면 이제 REST API의 조건중 하나를 만족시켰다고 할 수 있다.

구현

자 그러면 spring에서 이 조건을 만족시켜 데이터를 보내려고 하면 어떻게 하면 될까? 바로 hateoas 라이브러리를 사용하면 된다.

RepresentationModel

public class EventResource extends RepresentationModel {
//    @JsonUnwrapped
    private Event event;

    public EventResource(Event event) {
        this.event = event;
    }

    public Event getEvent() {
        return event;
    }
}

위와 같이 RepresentationModel class를 오버라이딩해서 사용하는 방법이 있다. 그리고

	@PostMapping
    public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors) {
		// event를 update후 저장, 그리고 반환된 newEvent
        Event event = modelMapper.map(eventDto, Event.class);
        event.update();
        Event newEvent = this.eventRepository.save(event);

        // 요청을 받아서 id를 추가한 후 body에 event를 담아서 반환한다.
        WebMvcLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(newEvent.getId());
        EventResource eventResource = new EventResource(event);
        eventResource.add(linkTo(EventController.class).withRel("query-events"));
        eventResource.add(selfLinkBuilder.withRel("update-event"));
        eventResource.add(linkTo(EventController.class).slash(event.getId()).withSelfRel());

		...

위와 같이 오버라이딩한 class를 불러와서 entity등록하고 add()를 이용해서 링크를 등록하고 withRel()를 이용해서 해당 링크의 이름에 대해서 명시해주면 된다. 그리고 자시 자신을 가리키는 링크를 사용할 떋는 withSelfRel()을 사용하는데 이는 리팩토링하여 오버라이딩한 class에 넣어주면 좀 더 관리하기에 편하다.

링크를 추가하는데 Link class의 생성자를 이용하는 방법도 있지만 type safe 하지 않기 때문에 linkTo()를 이용해서 링크를 걸어준다. 이렇게 해서 결과를 client가 받으면

다음과 같다. 근데 아직 뭔가 좀 이상하다. event 객체의 이름이 그대로 key값이 들어간 것이다. 이를 해결하기 위해서 @JsonUnwrapped annotaion을 이용하면

entity로 묶이지 않은 것을 알 수 있다.

EntityModel

public class EventResource extends EntityModel<Event> {
    public EventResource(Event event) {
        super(event);
        add(linkTo(EventController.class).slash(event.getId()).withSelfRel());
    }
}

두 번째 방법은 EntityModel을 오버라이딩하는 방법이다.

에러 페이지

올바르지 못 한 정보를 가지고 사용자가 요청하면 메인페이지로 안내하는 것이 일반적이다. 이도 마찬가지로 hateoas로 구현할 수 있다.

기존에는 response body에 error 메세지들을 담아서 응답하였으나, 이제는 error 메시지와 link도 함께 담아서 전송해야 한다.

ErrorsResource

public class ErrorsResource extends EntityModel<Errors> {
    public ErrorsResource(Errors content, Link... links) {
        super(content, List.of(links));
        add(linkTo(methodOn(IndexController.class).index()).withRel("index"));
    }
}

error 객체를 받고 link를 추가하는 class이다.

@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);
        }
        ...
    }

    private ResponseEntity badRequest(Errors errors) {
        return ResponseEntity.badRequest().body(new ErrorsResource(errors));
    }

hateoas를 이용해서 body에 에러메시지와 link를 모두 넣어서 응답을 만들어내면 된다.

@Test
    @Description("이상한 값을 요청했을 때 400에러를 발생하는 상황입니다.")
    public void createEvent_Bad_Request_Wrong_Input() throws Exception {
        EventDto eventDto = EventDto.builder()
                .name("Spring")
                .description("REST API Development with Spring")
                .beginEnrollmentDateTime(LocalDateTime.of(2018, 11, 26, 14, 21))
                .closeEnrollmentDateTime(LocalDateTime.of(2018, 11, 25, 14, 21))
                .beginEventDateTime(LocalDateTime.of(2018, 11, 24, 14, 21))
                .endEventDateTime(LocalDateTime.of(2018, 11, 23, 14, 21))
                .basePrice(10000)
                .maxPrice(200)
                .limitOfEnrollment(100)
                .location("강남역").build();

        this.mockMvc.perform(post("/api/events")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(this.objectMapper.writeValueAsString(eventDto)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("_links.index").exists())
                .andDo(print());

    }

그런데 이렇게 요청을 보내면은

body에 아무것도 담기지 않고 500에러를 받는다. 이는 spring boot가 2.3 이상으로 올라가면서 jackson이 array를 만드는 것을 허용하지 않기 때문이다. 그래서

gen.writeFieldName("errors");

이 한 줄을 에러를 직렬화해주는 코드에 추가하면은 해결된다. 왜일까?

이후의 에러 메세지를 파싱한 모습이다. hateoas에 의해서 _links안에 링크들이 생기게 되는데 기존에 위에 에러들은 특별한 필드로 묶이지 않았기 때문에 에러가 발생한 것으로 추측된다. 그러므로 위 에러들을 하나의 value로 묶어주는 필드가 필요한 것 같다.

profile
풀스택개발자가되고싶습니다:)

0개의 댓글