[Spring-REST-API] 2. HATEOAS & Self-Descriptive Message 적용

Spring HATEOAS

레퍼런스 : https://docs.spring.io/spring-hateoas/docs/current/reference/html/

  • HATEOAS 원칙을 따르는 Rest 표현을 쉽게 작성할 수 있는 API를 제공하는 라이브러리
  • 크게 링크를 만드는 기능과 리소스를 만드는 기능으로 나눌 수 있음

HATEOAS(in wikipedia)

  • REST 어플리케이션 아키텍처의 콤포넌트

  • Hypermedia As The Engine Of Application State

  • HATEOAS를 사용하면 클라이언트는
    (하이퍼 미디어를 통해 정보를 동적으로 제공하는)
    응용프로그램 서버와 interact(상호작용함)

  • 클라이언트는 하이버미디어의 일반적인 이해를 넘어서는 지식이 거의 필요치 않음

  • CORBA의 클라이언트 서버처럼 인터페이스 설명 언어(IDL)를 이용한 고정 인터페이스를
    사용해서 상호작용하는 것과 반대라고 볼 수 있음

  • HATEOAS가 서버 클라언트를 나누는 방식은 서버기능을 독립적으로 발전 가능하게

  • 어플리케이션의 상태의 변화에 따라 링크 정보가 바뀜

  • 잔액 조회시 예금,출금, 송금등 의 링크가 따라가지만 예금이 마이너스인 경우
    에는 링크가 예금만 생긴다던지

  • 설정

    • 스프링 부트 미사용시 @EnableHypermediaSupprort등의 애노테이션 등을 사용해야함
      (HATOAS 레퍼런스 하단 참조)
    • 스프링 부트 사용시 자동 설정됨

리소스 : 응답본문 + 링크, 응답본문이 다시 리소스로 중첩도 가능

link 예제
1
2
3
4
5
6
7
Link link = new Link("http://localhost:8080/something");
assertThat(link.getHref(), is("http://localhost:8080/something"));
assertThat(link.getRel(), is(Link.REL_SELF));

Link link = new Link("http://localhost:8080/something", "my-rel");
assertThat(link.getHref(), is("http://localhost:8080/something"));
assertThat(link.getRel(), is("my-rel"));
  • link 설명
    • new로 생성 가능
    • href, rel 재설정 가능
    • 기본 rel은 self
    • linkTo()를 이용해서 컨트롤러와 메소드에 매핑된 url을 읽어올 수 있음
    • 구조
      • HREF : URI 설정
      • rel :
        • self(default)-자기 자신
        • profile - 응답 몬문에 대한 문서로 링크
        • 이후 현재 API 상태이전을 할수 있는 어플리케이션 링크(예제에서는 2개)
          • events
          • update

1. TDD 시작

새로 추가된 부분
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//응답에 각각에 해당하는 링크 요소가 존재하는지
.andExpect(jsonPath("_links.self").exists())
.andExpect(jsonPath("_links.query-events").exists())
.andExpect(jsonPath("_links.update-event").exists())
;
```

리소스
- 거의 모든 리소스 표현에는 일부 링크 (최소한 `self`1개)가 포함되므로
presentation 클래스를 디자인할 때 실제로 상속할 기본 클래스를 제공

```java ResourceSupport를 상속해서 리소스 클래스를 만들 수 있다

import org.springframework.hateoas.ResourceSupport;

public class EventResource extends ResourceSupport {

private Event event;

//생성자
public EventResource(Event event) {this.event = event;}
//Getter
public Event getEvent() {return event;}
}
  • ResourceSupport를 상속하면 링크 추가가 매우 쉬워진다
1
2
3
4
5
6
7
8
9
10
11
12
13
//원래 컨트롤러 부분
URI uri = linkTo(EventController.class)
.slash(resultEvent.getId()).toUri();
return ResponseEntity.created(uri).body(resultEvent);

//바꾼 결과
URI uri = linkTo(EventController.class).slash(resultEvent.getId()).toUri();

EventResource eventResource = new EventResource(resultEvent);
eventResource.add(linkTo(EventController.class).withRel("query-events"));
eventResource.add(
linkTo(EventController.class).slash(resultEvent.getId()).withSelfRel());
return ResponseEntity.created(uri).body(eventResource);
  • 설명
    • resultEvent를 베이스로 만든 eventResource는 이제 링크 추가가 가능
    • 컨트롤러 클래스의 매핑 URI를 읽어와서 query-events 이름으로 rel 생성
    • Self 링크 주소는 어디에?
      • URI를 만드는 linkTo(EventController.class).slash(resultEvent.getId())
        가 정확한 링크임을 이용
      • variable로 추출해서 재사용해보자
재사용 까지 이용해서 수정해서 완성한 부분
1
2
3
4
5
6
7
8
9
ControllerLinkBuilder selfLinkBuilder =
linkTo(EventController.class).slash(resultEvent.getId());
URI uri = selfLinkBuilder.toUri();

EventResource eventResource = new EventResource(resultEvent);
eventResource.add(linkTo(EventController.class).withRel("query-events"));
eventResource.add( selfLinkBuilder.withSelfRel());
eventResource.add( selfLinkBuilder.withRel("update-event"));
return ResponseEntity.created(uri).body(eventResource);
  • 설명
    • 재사용을 위해서 selfLinkBuilder로 variable로 추출
    • 추출한 selfLinkBuilder를 self링크와 ‘update-event’ 릴레이션으로 add
    • 같은 url?
      • 무방함
      • update는 PUT 메소드이기 때문에 같은 URI여도 무방하다
      • 링크 자체에 어떤 메소드인지 constraint 할 순 없음

테스트하면 깨진다
받으려고하던 필드가 Event로 wrapping되서 나오기 떄문

  • 응답 본문에 event로 감싸져서 나오는 이유

    • ResponseEntity에서 리턴한 개게는 EventResource객체
    • 이 객체를 Json으로 컨버팅 하는 것은 objectMapper가 해주는 일
    • objectMapper가 사용하는 Serializer? BeanSerializer
    • BeanSerializer는 기본적으로 객체의 필드 이름을 사용한다
    • 그래서 event를 사용하게 된것
    • 그리고 해당 Event가 여러 다른 필드로 구성된 Composite field이므로 event안에
      구성 필드 값을 다 넣어준 것
  • 만약 event로 감싸고 싶지 않다면? 여러가지 방법이 있을 수 있음

    1. EventResource 안에 Event타입이 아니라 Event 내부의 composite한 필드를 넣는다
    • 이경우 생성자에 일일히 받아와야하나? 번거로워짐
    1. @JsonUnwrapped
    • EventResource안의 Event필드 위에 @JsonUnwrapped 애노테이션
    • 해당 필드의 wrapping을 벗겨준다
      @JsonUnwrapped 사용해서 래핑을 벗겨준다
      1
      2
      3
      4
      5
      6
      7
      public class EventResource extends ResourceSupport {

      @JsonUnwrapped
      private Event event;
      public EventResource(Event event) {this.event = event;}
      public Event getEvent() {return event;}
      }
    • 이렇게 하면 테스트는 성공
    • 코드량이 늘어났음. 다른 쉬운 방법?
    1. extends Resource 사용 방법
    • 레퍼런스에는 존재하지 않는 방법
    • 스프링 hateoas에 Resource라는 ResourceSupport를 상속하는 하위 클래스
      가 존재한다
      Resource정의 부분 소스
      1
      2
      3
      4
      5
      6
      7
      8
      9
      @XmlRootElement
      public class Resource<T> extends ResourceSupport {

      private final T content;
      //{중간 생략}
      //Getter
      @JsonUnwrapped
      @XmlAnyElement
      public T getContent() {return content;}
  • 설명

    • Resource는 ResourceSupport를 상속하고 있다
    • T를 content로 받아오고 있다
    • Getter인 getContent()메소드 위에 이미 @JsonUnwrapped이 붙어 있기 때문에
      이를 이용하면 개발자가 굳이 @JsonUnwrapped을 사용할 필요가 없을 듯하다
Resource를 이용한 EventResource
1
2
3
4
5
6
7
8
9
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Resource;

public class EventResource extends Resource<Event> {

public EventResource(Event content, Link... links) {
super(content, links);
}
}
  • 설명
    • 기존 EventResource를 ResourceSupport대신 Resource를 상속
    • 생성자 구현 , 기능 추가?
      • 예를들어 현재 self 링크 거는 부분을 이쪽에 옮긴다면?
셀프링크 거는 부분을 EventResource안으로 옮긴 예
1
2
3
4
5
6
public class EventResource extends Resource<Event> {
public EventResource(Event event, Link... links) {
super(event, links);
add(linkTo(EventController.class).slash(event.getId()).withSelfRel());
}
}
HATEOAS가 적용된 결과값
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
{
"id": 1,
"name": "springname",
"description": "spring rest api",
"beginEnrollmentDateTime": "2019-02-20T11:53:00",
"closeEnrollmentDateTime": "2019-02-21T11:55:00",
"beginEventDateTime": "2019-03-20T11:53:00",
"endEventDateTime": "2019-03-21T11:53:00",
"location": "강남",
"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 REST Docs

  • 정확하고 읽기 쉬운 Restful서비스를 위한 문서를 생성할 떄 도와줌
  • Spring Mvc Test로 자동생성된 snippets들과 직접 작성한 문서들을 결합해서 문서화
  • 스프링만 사용한다면 MockMvc생성 할떄 설정을 추가 해야한다
    스프링 사용시 MockMvc생성에 설정부분을 추가한 before 메서드 추가
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    private MockMvc mockMvc;

    @Autowired
    private WebApplicationContext context;

    @Before
    public void setUp() {
    this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
    .apply(documentationConfiguration(this.restDocumentation))
    .build();
    }
  • 스프링 부트 사용시?
    • @AutoConfigureRestDocs를 테스트위에 설정
    • 위의 작업이 알아서 자동으로
rest docs 적용
1
2
3
4
5
mockMvc.perform(post("/api/events")
//중간 부분 생략
.andExpect(jsonPath("_links.update-event").exists())
.andDo(document("create-event")) //새로 추가한 부분
;
  • 설명

    • @AutoConfigureRestDocs를 테스트에 설정하였다
    • 그러면 이제 MockMvc는 RestDocsMockMvc 가 된다
    • 그후 위처럼 하면 target 밑에 create-event에 대한 snippets을 생성한다
    • request,response, 그리고 각 body에 대한 adocs를 생성하는 것을 확인 가능하다
  • RestDocsMockMvc 커스터마이징

    • 현재 반환되는 body는 포매팅이 안되어서 JSON 본문을 읽기가 어렵다
    • 포매팅을 위해선 기본 제공되는 RestDocsMockMvc의 커스터마이징이 필요
    • RestDocsMockMvcConfigurationCustomizer를 이용해서 커스터마이징이 가능하다
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      @TestConfiguration
      public class RestDocsConfiguration {
      @Bean
      public RestDocsMockMvcConfigurationCustomizer restDocsMockMvcConfigurationCustomizer(){
      return new RestDocsMockMvcConfigurationCustomizer() {
      @Override
      public void customize(MockMvcRestDocumentationConfigurer configurer) {
      //configurer만 구현하면 된다
      configurer.operationPreprocessors()
      .withRequestDefaults(prettyPrint())
      .withResponseDefaults(prettyPrint());
      }
      }
      }
      }

      //인텔리J는 람다로 표현을 바꿔준다
      @Bean
      public RestDocsMockMvcConfigurationCustomizer restDocsMockMvcConfigurationCustomizer() {
      return configurer -> configurer.operationPreprocessors()
      .withRequestDefaults(prettyPrint())
      .withResponseDefaults(prettyPrint());
      }
  • 설명

    • @TestConfiguration은 Test에서만 쓰는 @Configuration이라고 보면 된다
    • RestDocsMockMvcConfigurationCustomizer 빈을 등록할 때 customize만 구현
    • RestDocsMockMvcConfigurationCustomizer가 FunctionalInterface이므로
      인텔리J는 자동으로 람다를 추천
    • configure만 구현 하면 된다
    • withRequestDefaults, withResponseDefaults로 요청과 응답에 대한 기본
      operationPreprocessors를 설정 할 수 있다
    • prettyPrint()는 요청과 응답을 보기 좋게 찍어주는 operationPreprocessors를 리턴
    • 실제 이 자바 설정 파일을 적용하기 위해 Test에서 @Import한다
    • 해보면 이제 만들어진 요청 본문 문서, 응답 본문 문서 모두 가독성이 좋게 되어있다

Request, REsponse Field와 헤더의 문서화

API 문서 만들기를 하는 도중이다

  • 요청 본문 문서화(앞에서 만듬)
  • 응답 본문 문서화(앞에서 만듬)
  • 링크 문서화
    • self
    • 상태이전: 예제에서는 query-events- update-event
    • profile 링크
  • 요청 헤더 문서화
  • 요청 필드 문서화
  • 응답 헤더 문서화
  • 응답 필드 문서화

문서화 방법

  • 링크 문서화: links() + linkWithRel()

  • 요청 헤더 문서화: requestHeaders() + headerWithName()

  • 요청 필드 문서화: requestFields() + fieldWithPath()

  • 응답 헤더 문서화: responseHedaers() + headerWithName()

  • 응답 필드 문서화: responseFields() + fieldWithPath()

  • 링크문서화

    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
    // 링크, 요청헤더, 요청필드, 응답 헤더 문서화
    .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 event")
    ),
    requestHeaders(
    headerWithName(HttpHeaders.ACCEPT).description("accept header"),
    headerWithName(HttpHeaders.CONTENT_TYPE).description("content type")
    ),
    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("basePrice of new Event"),
    fieldWithPath("maxPrice").description("maxPrice of new Event"),
    fieldWithPath("limitOfEnrollment").description("limitOfEnrollment of new Event")
    ),
    responseHeaders(
    headerWithName(HttpHeaders.CONTENT_TYPE).description("content type"),
    headerWithName(HttpHeaders.LOCATION).description("Location")
    ),

    relaxedResponseFields(
    fieldWithPath("id").description("Id 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("basePrice of new Event"),
    fieldWithPath("maxPrice").description("maxPrice of new Event"),
    fieldWithPath("limitOfEnrollment").description("limitOfEnrollment of new Event"),
    fieldWithPath("offline").description("offline of new Event"),
    fieldWithPath("free").description("free of new Event"),
    fieldWithPath("eventStatus").description("eventStatus of new Event")
    )
    ));
  • 설명

    • 원래 document(“create-event”)만 있던 것 뒤에 links가 더 붙어 있다
    • document(String identifier, Snippet… snippets) 구조이기 때문에 계속적으로
      스니펫을 추가 가능하다
    • links()는 LinkDescriptor들을 인자로 가진다
    • 응답에서 추출된 링크들이 이 LinkDescriptor들을사용되서 문서화된다,
    • 이렇게 하면 이제 links.adoc가 만들어진다
    • adoc파일 안에는 linkWithRel로 정의한 3가지의 정보가 들어있다
    • requestHeaders()안에 headerWithName으로 HeaderDescriptor를 인자로 넣었다
    • requestField는 기본 snippets의 http-request를 보고 만드는게 편하다
    • responseHeaders, responseFields도 비슷한 방법으로 가능하다
    • 응답을 responseFields가 아닌 relaxedResponseFields를 쓰는 이유?
      • responseFields로 하면 에러 발생
      • 응답 안의 _links에 대한 description을 요구
      • 응답은 링크 문서화의 links()로 이미 만들어 주었다
      • 그럼에도 응답의 일부라고 생각하고 요구하는 것
      • relaxed접두사가 붙으면 해당 필드 전부를 하지 않아도 된다
    • relax접두사의 장단점
      • 장점: 문서 일부분만 테스트 가능
      • 단점: (일부만 기술하므로)정확한 문서를 만들지 못한다
relaxedResponseFields대신 responseFields 쓰는 방법
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
58
59
60
61
62
63
fieldWithPath("_links.self.href").description("Link to self"),
fieldWithPath("_links.query-events.href").description("Link to query-event"),
fieldWithPath("_links.update-event.href").description("Link to update-event")
```
- **links()에서 지정된 부분을 responseFields에서 안해도 되는 방법은 없을까?**
- 개인적으론 description대신 .ignored()를 사용해도 좋을 것 같은데..


# 문서 빌드
스니펫을 만들면 이제 그 스니펫으로 문서를 빌드하여야한다
pom.xml에 다음의 의존성을 추가한다
```xml pom.xml
<plugins>
<plugin>
<groupId>org.asciidoctor</groupId>
<artifactId>asciidoctor-maven-plugin</artifactId>
<version>1.5.3</version>
<executions>
<execution>
<id>generate-docs</id>
<phase>prepare-package</phase>
<goals>
<goal>process-asciidoc</goal>
</goals>
<configuration>
<backend>html</backend>
<doctype>book</doctype>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-asciidoctor</artifactId>
<version>2.0.2.RELEASE</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>2.7</version>
<executions>
<execution>
<id>copy-resources</id>
<phase>prepare-package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>
${project.build.outputDirectory}/static/docs
</outputDirectory>
<resources>
<resource>
<directory>
${project.build.directory}/generated-docs
</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
  • 설명
    • Maven plugin 3개가 필요
      • asciidoctor-maven-plugin
        • html을 생성한다
        • 내용을 보면 prepare-package 페이즈에 asciidoc을 처리하는 goal을 끼워놓음
        • process-asciidoc
          • 이 플러그인이 제공하는 Maven goal
          • src/main/asciidoc하위 모든 adoc파일을 html로 만든다
          • target/generated-docs밑에 생성
      • spring-restdocs-asciidoctor
      • maven-resources-plugin
        • prepare-package 페이즈에 copy-resources 라는 goal을 실행
        • 설정한것 처럼 getnerated-docs의 내용을 static/docs로 옮긴다
        • 만든 html이 있어야하므로 asciidoctor-maven-plugin이 먼저 실행되어야 한다
        • 같은 페이즈이니 pom.xml의 순서에 따라 순서가 달라지니 순서에 유의
        • static으로 옮겨졌으니 스프링부트의 정적리소스 지원 기능으로 아무 설정 없이
          해당 html을 요청할 수 있다
    • 처음에 인텔리J에서 Spring Rest Docs를 의존성을 추가하면
      maven-resources-plugin만 추가하면 된다
      추가가 된다
    • 2.0.3RELEASE가 안된다.. 2.0.2RELEASE로 하면 된다. 왜 안되는건지
    • asciidoc 밑에 템플릿용 파일을 끼워넣으면 위에서 설명한 절차에 따른 html이 생성
      된다
1
2
3
4
5
6
=== 이벤트 생성

`POST` 요청을 사용해서 새 이벤트를 만들 수 있다.

operation::create-event[snippets='request-fields,curl-request,http-request,request-headers,http-response,response-headers,response-body,response-fields,links']

  • 설명
    • asscidoc/index.adoc 일부분
    • html로 생성할때 operation::create-event에서 generated-snippets에 만든
      스니펫을 참조한다

이제 저 html을 profile 링크로 추가하면 된다

profile add까지 추가한 EventResource
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class EventResource extends Resource<Event> {

public EventResource(Event event, Link... links) {
super(event, links);

ControllerLinkBuilder controllerLinkBuilder = linkTo(EventController.class);
add(controllerLinkBuilder.withRel("query-events")); //event rel

ControllerLinkBuilder selfLinkBuilder = controllerLinkBuilder.slash(event.getId());
add(selfLinkBuilder.withSelfRel()); //self rel
add(selfLinkBuilder.withRel("update-event")); //update rel: put요청이라 상관없음

//profile
add(new Link("/docs/index.html#resources-events-create").withRel("profile"));
}
}

DB 분리

test할 때는 H2, 서비스시 PostgresQL
현재 방법

  • application.properties를 src/resource, test/resource 2곳에 마련
  • 두곳에 각각 다른 DB 설정
  • test시 덮어 써버림
  • 중복 코드가 너무 많음

해결 방법

  • 덮어 쓰지 않게 다른 이름으로 ex)application-test.properties
  • @ActiveProfiles(“test”)
  • 프로파일 때 배웠던것처럼 applicaion-{프로파일이름}.properties를 적용이됨
  • 이제 공통부분을 중복하지 않아도 됨

index

다른 리소스에 대한 링크를 제공한다

test클래스 먼저
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@Import(RestDocsConfiguration.class)
@ActiveProfiles("test")
public class IndexControllerTest {
@Autowired
MockMvc mockMvc;

//@Autowired
//ObjectMapper objectMapper;

@Test
@Description("index 페이지로 들어가면 각각의 리소스에 대한 루트가 나오면 좋겠다, 현재는 /api/events뿐")
public void index() throws Exception {
mockMvc.perform(get("/api"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("_links.events").exists());
}
}
테스트 성공하도록 컨트롤러 작성
1
2
3
4
5
6
7
8
9
@RestController
public class IndexController {
@GetMapping("/api")
public ResourceSupport root() {
ResourceSupport index = new ResourceSupport();
index.add(linkTo(EventController.class).withRel("events"));
return index;
}
}
  • 설명
    • TDD 흉내로 테스트먼저 만들고 성공하도록 완성
    • 이렇게 하면 해당 /api시 evnets에 대한 링크가 제공된다

에러 리소스

  • BadRequest에 대한 테스트를 만들었고 그에 대한 응답과 바디도 만들었지만
    이 에러 응답에 첫페이지만 갈 수 있다
  • 에러를 받고 다음 상태 전이 할 수 있는 링크가 없음
  • 에러가 났을때도 인덱스로 가는 링크가 있다면 좋을 것이다
  • BadRequest나 에러에 대한 테스트 조건에 위의 테스트를 넣고
    해당 에러 핸들러의 에러 응답 작성시 링크를 추가하도록 하면 될 것이다
    • 테스트 조건: .andExpect(jsonPath("_links.index").exists())
1
2
3
4
5
6
7
public class ErrorsResource extends Resource<Errors> {
public ErrorsResource(Errors content, Link... links) {
super(content, links);
//add index link
add(linkTo(methodOn(IndexController.class).index()).withRel("index"));
}
}
  • 컨트롤러의 BadReqeust던질때 ErrorsResource로 래핑해서 던지면 됨
    return ResponseEntity.badRequest().body(new ErrorsResource(errors))
  • 테스트 시 에러 발생 : $[0].xxx부분
    • error가 content안에 들어가있는 에러에 Unwrapped가 되지 않아서 발생
    • Reource에는 @JsonUnwrapped가 있어서 자동으로 Unwrapped가 된다 했었다
    • @JsonUnwrapped 애노테이션 주석을 읽어보면 JSON Array에는 애노테이션 적용이
      안된다고 나와있다
    • 굳이하려면 Serializer를 재정의 하면 된다
    • 이정도에선 그냥 테스트를 content안을 보게 하는게 나을 듯
    • $[0].xxx -> content[0].xxx로 수정

또 다른 에러 발견 및 통과

  • 다른 테스트는 다 통과하였는데 createEvent_BadRequest_wrongValue에서만
    에러가 발생하였다
  • 에러 발생 이유 application.properties에 정의한 objectMapper 커스텀 프로퍼티
    spring.jackson.deserialization.fail-on-unknown-properties=true
  • 이 프로퍼티를 true한 경우 JsonMappingException를 일으킨다
  • 이 에러는 Exception이므로 기존 error에는 처리할 수 없다
  • 나 같은 경우에는 이전 Errors를 다루둣이 Exception을 다룰 수 있는
    ExceptionResource,와 ExceptionSerializer를 생성하였다
  • 그리고 컨트롤러에 에러 핸들러를 추가하면 끝
  • 코드로 보면
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
  //컨트롤러에 있는 Exception핸들러 부분
@ExceptionHandler(JsonMappingException.class)
public ResponseEntity handleError(Exception e){
return ResponseEntity.badRequest().body(new ExceptionResource(e));
}

//Serializer
@JsonComponent
public class ExceptionSerializer extends JsonSerializer<Exception> {

@Override
public void serialize(Exception e, JsonGenerator gen,
SerializerProvider serializers) throws IOException {
gen.writeStartArray();
gen.writeStartObject();
gen.writeStringField("exceptionMessage",e.getMessage());
gen.writeStringField("exceptionClass",e.getClass().toString());
gen.writeEndObject();
gen.writeEndArray();
}
}

//Resource<T>
public class ExceptionResource extends Resource<Exception> {
public ExceptionResource(Exception content, Link... links) {
super(content, links);
add(linkTo(methodOn(IndexController.class).index()).withRel("index"));
}
}

이제 이러면 해당 에러에도 index가 링크로 담겨있는 것을 확인할 수 있다

이벤트 목록 조회 API 만들기

페이지, 정렬

  • 스프링 Data JPA가 제공하는 Pageable사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//테스트 코드
this.mockMvc.perform(get("/api/events")
.param("page","1")
.param("size","10")
.param("sort","name,DESC")
)
.andDo(print())
.andExpect(status().isOk());

//컨트롤러에서 Getter
@GetMapping
public ResponseEntity queryEvents(Pageable pageable){
return ResponseEntity.ok(eventRepository.findAll(pageable));
}
  • 설명
    • 파라미터로 page,size, sort를 받는다
    • 이를 Pageable안에 매핑되서 사용
    • page는 0부터 시작하기 때문에 현재 테스트는 2번째 페이지를 호출한 것이다
    • size는 한 페이지의 아이템 길이로 현재 10개로 되어있다
    • sort는 name을 내림차순으로 소트하도록 주었다
      page의 리턴의 경우 아래와 같은 형태로 리턴된다
      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
      {
      "content": [
      {

      //10개 Event Array

      }
      ],
      "pageable": {
      "sort": {
      "sorted": true,
      "unsorted": false,
      "empty": false
      },
      "offset": 10,
      "pageNumber": 1,
      "pageSize": 10,
      "unpaged": false,
      "paged": true
      },
      "totalElements": 30,
      "totalPages": 3,
      "last": false,
      "size": 10,
      "number": 1,
      "sort": {
      "sorted": true,
      "unsorted": false,
      "empty": false
      },
      "numberOfElements": 10,
      "first": false,
      "empty": false
      }
  • findall()리턴시 Page가 리턴됨
  • sort pageable 정보가 들어 있다
  • 파라미터와 매칭 되는 부분 잘 읽어 보기

페이지 링크

  • 앞페이지나 뒷페이지에 대한 정보가 없음
  • 페이지를 리소스로 바꿔서 링크로 추가가 필요하다
  • 간단하게 생각하면 new EventResource(Event event)를 해주면 되는데
    현재 테스트 데이터 30개를 전부 이렇게 순회하면서 직접 할 것인가?
    더 좋은 방법이 있는데
  • 유용한 것이 Spring Data JPA가 제공해주는 PagedResourcesAssembler라는 것이 있음
  • 이 리소스어셈블러를 이용하면 page 인스턴스를 간단하게 pagedResource로 바꿀 수
    있음
    PagedResourcesAssembler라는를 적용한 방법
    1
    2
    3
    4
    5
    6
    @GetMapping
    public ResponseEntity queryEvents(Pageable pageable, PagedResourcesAssembler<Event> assembler){
    Page<Event> page = eventRepository.findAll(pageable);
    PagedResources<Resource<Event>> pagedResources = assembler.toResource(page);
    return ResponseEntity.ok(pagedResources);
    }
  • 설명
    • 이전에 리턴하던 findall()리턴값은 Page 형태였다
    • PagedResourcesAssembler
      • implements ResourceAssembler<Page, PagedResources<Resource>>
      • 상속받고 있는 ResourceAssembler<T, D extends ResourceSupport>은
        기본적으로 도메인 T를 ResourceSupport D로 컨버팅 하기 위한 어셈블러
      • PagedResourcesAssembler는 정의에 보이다시피 페이지에 쓰일 수 있게 되어있음
      • Page를 받은 후 PagedResources<Resource> 변환됨을 주의
바뀐 리턴값, 링크 정보가 추가됨을 알 수 있다
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
{
"_embedded": {
"eventList": [
{
"id": 27,
"name": "event_26",
"description": "test event-26",
"beginEnrollmentDateTime": null,
"closeEnrollmentDateTime": null,
"beginEventDateTime": null,
"endEventDateTime": null,
"location": null,
"basePrice": 0,
"maxPrice": 0,
"limitOfEnrollment": 0,
"offline": false,
"free": false,
"eventStatus": null
},


//위의 타입이 10개
]
},
"_links": {
"first": {
"href": "http://localhost:8080/api/events?page=0&size=10&sort=name,desc"
},
"prev": {
"href": "http://localhost:8080/api/events?page=0&size=10&sort=name,desc"
},
"self": {
"href": "http://localhost:8080/api/events?page=1&size=10&sort=name,desc"
},
"next": {
"href": "http://localhost:8080/api/events?page=2&size=10&sort=name,desc"
},
"last": {
"href": "http://localhost:8080/api/events?page=2&size=10&sort=name,desc"
}
},
"page": {
"size": 10,
"totalElements": 30,
"totalPages": 3,
"number": 1
}
}
  • 설명
    • _embedded라는 것안에 List가 들어갔다
    • 페이지와 링크 정보가 추가가 되어있음을 확인할 수 있다
    • HATEOAS ? No, 각 이벤트들의 링크가 없음
      HATEOAS 적용을 위한 소스 수정
      1
      2
      3
      4
      5
      6
      7
      8
      @GetMapping
      public ResponseEntity queryEvents(Pageable pageable, PagedResourcesAssembler<Event> assembler){
      Page<Event> page = eventRepository.findAll(pageable);
      // PagedResources<Resource<Event>> pagedResources = assembler.toResource(page);
      PagedResources<EventResource> pagedResources = assembler.toResource(page, e -> new EventResource(e));
      pagedResources.add(new Link("/docs/index.html#resources-events-list").withRel("profile"));
      return ResponseEntity.ok(pagedResources);
      }
  • 설명
    • 이전에 있던 소스 assembler.toResource(page)의 동작을 잘 생각해보자
      PagedResourcesAssembler클래스의 메소드 일부
      1
      2
      3
      4
      5
      6
      7
      8
      //1. 이전에 있던 소스 동작 , 자동으로 2번으로
      public PagedResources<Resource<T>> toResource(Page<T> entity) {
      return toResource(entity, it -> new Resource<>(it));
      }
      // 2번
      public <R extends ResourceSupport> PagedResources<R> toResource(Page<T> page, ResourceAssembler<T, R> assembler) {
      return createResource(page, assembler, Optional.empty());
      }
    • 즉 이전 소스를 넣으면 알아서 자동으로 new Resource로 만들고 있음
    • 이를 직접 사용하도록 2번호출을 하되 new EventResource(e)로 하고 있다
    • 이렇게 하면 이전에 EventResource에 정의한 링크들이 붙을 것이다
    • 이제 프로필 링크까지 테스트 해도 통과한다
    • 각각의 프로필이 다르기 때문에 EventResource에서 추가해주는 링크에서 프로필 제외
    • 문서 완성은…나중에
테스트 코드
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
@Test
@TestDescription("30개의 이벤트,페이징 크기 10, 3페이지=>2번째 페이지 조회 ")
public void queryEvents() throws Exception{
//Given: 이벤트 30개
IntStream.range(0,30).forEach(this::generatedEvent);

//When
this.mockMvc.perform(get("/api/events")
.param("page","1")
.param("size","10")
.param("sort","name,DESC")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaTypes.HAL_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("page").exists())
.andExpect(jsonPath("_links.self").exists())
.andExpect(jsonPath("_links.profile").exists())
.andExpect(jsonPath("_embedded.eventList[0]._links.self").exists())
.andDo(document("query-events"))
;

}
private Event generatedEvent(int index) {
Event event = Event.builder()
.name("event_"+index)
.description("test event-"+index)
.build();
return eventRepository.save(event);
}

이벤트 조회 API

TDD, 404에러가 나온다
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
@Test
@TestDescription("저장된 이벤트 하나 조회")
public void getEvent() throws Exception{
//Given
Event event = generatedEvent(100);
//expected
mockMvc.perform(get("/api/events/{id}", event.getId()))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("id").exists())
.andExpect(jsonPath("name").exists())
.andExpect(jsonPath("_links.self").exists())
.andExpect(jsonPath("_links.profile").exists())
;
}

@Test
@TestDescription("없는 이벤트 조회해서 404응답받기")
public void getEmpty() throws Exception{
//expected
mockMvc.perform(get("/api/events/1818"))
.andDo(print())
.andExpect(status().isNotFound())
;

}

  • 설명
    • 테스트는 2가지
TDD 성공하도록 구현
1
2
3
4
5
6
7
8
9
10
@GetMapping("/{id}")
public ResponseEntity getEvent(@PathVariable Long id){
Optional<Event> optionalEvent = eventRepository.findById(id);
if(optionalEvent.isEmpty()){
return ResponseEntity.notFound().build();
}
EventResource eventResource = new EventResource(optionalEvent.get());
eventResource.add(new Link("/docs/index.html#resources-events-get").withRel("profile"));
return ResponseEntity.ok().body(eventResource );
}
  • 설명
    • 컨트롤러에 RequestMapping주소가 있으면 메소드의 주소와 컴바인되서 연결
    • 네임 홀더내용은 PathVariable로 가져올 수 있다
    • 만약 가져온 값이 비었으면 404 에러 발생하도록 -> empty 테스트 만족
    • 비어있지않으면 Optional에서 Event를 빼서 resource로 만든다
    • 프로필 링크를 추가한후 반환한다

이벤트 수정 API

  • 지금까지 예제의 총 재활용
테스트 코드
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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
@Test
@TestDescription("정상적인경우 200과 함께 이벤트 리소스 응답")
public void updateEventsSuccessTest() throws Exception{
//Given
Event event = generatedEvent(777);
EventDto eventDto = modelMapper.map(event, EventDto.class);
String eventName = "name update";
eventDto.setName(eventName);

//expected
mockMvc.perform(put("/api/events/{id}",event.getId())
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsString(eventDto))
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("name").value(eventName))
.andExpect(jsonPath("_links.self").exists())
.andDo(document("update-event")); //문서는 더 자세히 추가 예정
}

@Test
@TestDescription("입력값이 없는 경우 테스트 수정 실패")
public void updateEventsFailTest1() throws Exception{
//Given
Event event = generatedEvent(777);
EventDto eventDto = new EventDto();
//expected
mockMvc.perform(put("/api/events/{id}",event.getId())
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaTypes.HAL_JSON)
.content(objectMapper.writeValueAsString(eventDto))
)
.andDo(print())
.andExpect(status().isBadRequest());
}

@Test
@TestDescription("입력값이 잘못된 경우 테스트 수정 실패")
public void updateEventsFailTest2() throws Exception{
//Given
Event event = generatedEvent(777);
EventDto eventDto = modelMapper.map(event, EventDto.class);
eventDto.setBasePrice(2000);
eventDto.setMaxPrice(1000);
//expected
mockMvc.perform(put("/api/events/{id}",event.getId())
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaTypes.HAL_JSON)
.content(objectMapper.writeValueAsString(eventDto))
)
.andDo(print())
.andExpect(status().isBadRequest());
}


@Test
@TestDescription("존재하지 않는 이벤트 수정시 실패")
public void updateEventsFailTest3() throws Exception{
//Given
Event event = generatedEvent(777);
EventDto eventDto = modelMapper.map(event, EventDto.class);

//expected
mockMvc.perform(put("/api/events/181818")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaTypes.HAL_JSON)
.content(objectMapper.writeValueAsString(eventDto))
)
.andDo(print())
.andExpect(status().isNotFound());
}

//generate Event
private Event generatedEvent(int index) {
Event event = new Event().builder()
//.id(777L)
.name("event_"+index)
.description("test event-"+index)
.beginEnrollmentDateTime(LocalDateTime.of(2019, 3, 20, 11, 53))
.closeEnrollmentDateTime(LocalDateTime.of(2019, 3, 21, 11, 55))
.beginEventDateTime(LocalDateTime.of(2019, 3, 22, 11, 53))
.endEventDateTime(LocalDateTime.of(2019, 3, 23, 11, 53))
.basePrice(100)
.maxPrice(200)
.limitOfEnrollment(100)
.location("강남")
.free(false)
.offline(true)
.eventStatus(EventStatus.DRAFT)
.build();

return eventRepository.save(event);
}
  • 설명
    • 테스트들을 잘 살펴볼 것 - 지금까지의 테스트 코드와 비슷
    • 온전한 event생성을 위해 generatedEvent에서 온전한 event생성

구현체는 다음과 같다

@PutMapping("/{id}")
public ResponseEntity updateEvent(@PathVariable Long id,
                                  @RequestBody @Valid EventDto eventDto,
                                  Errors errors){
    Optional<Event> optionalEvent = eventRepository.findById(id);
    if(optionalEvent.isEmpty()){
        return ResponseEntity.notFound().build();
    }
    if(errors.hasErrors()){
        errors.getAllErrors().forEach(objectError -> {
            log.info("**ERROR: "+objectError);
        });
        return badRequestWithErrorsResource(errors);
    }

    eventValidator.validate(eventDto,errors);
    if(errors.hasErrors()){
        errors.getAllErrors().forEach(objectError -> {
            log.info("**ERROR: "+objectError);
        });
        return badRequestWithErrorsResource(errors);
    }

    Event existingEvent = optionalEvent.get();
    modelMapper.map(eventDto,existingEvent);
    Event updatedEvent = eventRepository.save(existingEvent);
    EventResource eventResource = new EventResource(updatedEvent);
    eventResource.add(new Link("/docs/index.html#resources-events-update").withRel("profile"));
    return ResponseEntity.ok().body(eventResource);
}
  • 설명
    • 역시 지금까지 보아왔던 것의 종합판
    • valid가 끝나면 들어온값으로 원래 값을 덮어 씌우면 된다
    • modelMapper를 이용해 갚을 덮어 씌운 후 반영을 위해 update()
    • 그리고 저장한후 해당 이벤트를 프로필 링크 추가한 리소스 형태로 반환

이로써 API 구현은 끝이지만 끝이 아님? 응?

Related POST

공유하기