REST API -HATEOAS

mmeo0205·2022년 4월 28일
0
post-thumbnail

3-1.Spring HATEOAS 소개


HATEOAS 원칙을 따르는 REST 표현(링크 생성 및 표현)을 쉽게 생성할 수 있는 몇 가지 API 제공
HATEOAS 를 사용하기 위해 @EnableEntityLinks, @EnableHypermediaSupport 와 같은
설정들을 직접 적용해야 하지만 Spring Boot 가 HATEOAS 와 관련된 주석을 자동 설정하여 바로 사용 가능

3-2.HATEOAS 적용


HATEOAS v1.0 이상(Spring boot >= 2.2.0)을 사용하는 경우 클래스 이름 변경

ResourceSupport -> RepresentationModel
Resource -> EntityModel
Resources -> CollectionModel
PagedResources -> PagedModel
ResourceAssembler -> RepresentationModelAssembler

객체에서 링크도 같이 반환하기 위해 RepresentationModel 을 상속받는 클래스 작성

첫번째 방법

@JsonUnwrapped : object -> json serialize 할 때 event 객체로 감싸지 않는다

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

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

    ...
}

두번째 방법

EntityModel 은 RepresentationModel 하위 클래스로 content(예시 : Event) 를
호출하는 getContent 메서드에 @JsonUnwrapped 주석이 선언되어 있어서
별도의 어노테이션 작업이 필요없어 진다

public class EventResource extends EntityModel<Event> {
    public EventResource(Event event) {
        super(event);
    }
}

테스트

클라이언트에게 전달한 응답 본문에 link 가 전달되는지 확인하는 테스트 코드

    @Test
    @DisplayName("정상적으로 이벤트를 생성하는 테스트")
    public void createEvent() throws Exception {
        ... EventDto 빌더 생략

        mockMvc.perform(post("/api/events/")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaTypes.HAL_JSON)
                        .content(objectMapper.writeValueAsString(eventDto)))
                .andDo(print())
                .andExpect(jsonPath("_links.query-events").exists())
                .andExpect(jsonPath("_links.self").exists())
                .andExpect(jsonPath("_links.update-event").exists());
    }

3-3.Spring REST Docs 소개


RESTful 서비스 문서화를 도와주는 역할
Asciidoctor 로 작성한 문서와 Spring MVC Test로 생성된 스니펫을 결합
Swagger 에서는 적용할 수 없는 테스트를 강제화 할 수 있다

3-4.Spring REST Docs 적용


Test Class 의 클래스 레벨에 @AutoConfigurationRestDocs 주석을 달아주고
mockMvc.perform 응답에 .andDo(document("식별할 이름"));
위 내용을 아래와 같이 코드로 작성하고 실행하면 target 하위에 generated-snippets 폴더가 생성되고
그 아래에 snippet(확장자 : adoc) 들이 생성된다

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
public class EventControllerTests {
    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    @DisplayName("정상적으로 이벤트를 생성하는 테스트")
    public void createEvent() throws Exception {
        ...EventDto 빌더 생략

        mockMvc.perform(post("/api/events/")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaTypes.HAL_JSON)
                        .content(objectMapper.writeValueAsString(eventDto)))
                .andDo(document("create-event"));
    }

Spring REST Docs 커스텀

json data 가 한줄로 나오며 보기가 불편한데 보기 쉽게 커스텀할 수 있다
이외에 여러가지 커스텀 참조

테스트 패키지안에 테스트에서만 사용할 configuration 클래스 작성
@TestConfiguration : 테스트에서만 사용하는 Configuration

@TestConfiguration
public class RestDocsConfiguration {
    @Bean
    public RestDocsMockMvcConfigurationCustomizer restDocsMockMvcConfigurationCustomizer() {
        return configurer -> {
            configurer.operationPreprocessors().withRequestDefaults(prettyPrint());
            configurer.operationPreprocessors().withResponseDefaults(prettyPrint());
        };
    }
}

등록 방법으로 테스트에서 사용하는 Configuration 클래스를 import 해주면 바로 적용된다

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@Import(RestDocsConfiguration.class)
public class EventControllerTests {
    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    @DisplayName("정상적으로 이벤트를 생성하는 테스트")
    public void createEvent() throws Exception {
        ...EventDto 빌더 생략

        mockMvc.perform(post("/api/events/")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaTypes.HAL_JSON)
                        .content(objectMapper.writeValueAsString(eventDto)))
                .andDo(print())
                .andExpect(status().isCreated())
                .andDo(document("create-event"));
    }
}

다른 방법으로는 빈으로 등록하지 않고 각 테스트마다 적용하는 방법도 있다

	.andDo(document("create-event", Preprocessors.preprocessRequest(Preprocessors.prettyPrint())));

3-5.Spring REST Docs 적용


document 안에 links 와 request, response 의 헤더, 필드에 대한 내용을 적어주고 테스트를 실행하면
3-4 에서 말했던 경로 아래 작성한 개수만큼 snippet 이 생성된다
아래 코드를 보면 links 를 통해서 links 테스트를 진행했는데 responseFields 에도 link 가 포함되어 있어
다시 한번 테스트를 진행해야 하므로 작성해주었다
links 반복 테스트를 무시하는 방법으로 relaxedResponseFields 를 사용할 수 있는데 장단점은 아래와 같다
장점 : 문서 일부분만 테스트, 단점 : 정확한 문서를 작성하지 못함

.andDo(document("create-event",
        links(
                linkWithRel("query-events").description("link to query events"),
                linkWithRel("self").description("link to self"),
                linkWithRel("update-event").description("link to update event")
        ),
        requestHeaders(
                headerWithName("Content-Type").description("des content type"),
                headerWithName("Accept").description("des accept")
        ),
        requestFields(
                fieldWithPath("name").description("des name"),
                ... 생략
        ),
        responseHeaders(
                headerWithName("Location").description("des location"),
                headerWithName("Content-Type").description("des content type")
        ),
        responseFields(
                fieldWithPath("id").description("des id"),
				... 생략
                fieldWithPath("_links.query-events.href").description("des _links.query-events"),
                fieldWithPath("_links.self.href").description("des _links.self"),
                fieldWithPath("_links.update-event.href").description("des _links.update-event")
        )
));

3-6.Spring REST Docs 문서 빌드


플러그인 추가

target-generated-docs(경로)-index.html(파일) : asciidoctor-maven-plugin 이 생성하는 파일
target-classes-static-docs(경로)-index.html(파일) : maven-resources-plugin 이 생성하는 파일
plugin 의 순서가 중요한데 이유는 plugin 안에 goals-goal 을 보면 첫번째 플러그인은
process-asciidoc 로 설정되어 있고 두번째 플러그인은 copy-resources 로 설정되어있다
첫번째 플러그인의 작업을 통해 생성한 파일을 두번째 플러그인이 복사한다

<build>
    <plugins>
        <plugin>
            <groupId>org.asciidoctor</groupId>
            <artifactId>asciidoctor-maven-plugin</artifactId>
            <version>1.5.8</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>${spring-restdocs.version}</version>
                </dependency>
            </dependencies>
        </plugin>
        <plugin>
            <artifactId>maven-resources-plugin</artifactId>
            <version>3.0.1</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>
    </plugins>
</build>

플러그인 추가하여 메이븐 패키징 후 html 파일 생성 확인
profile 을 추가하는 코드에서 이슈 발생 아래와 같이 문제 해결하고 테스트까지 진행
profile 관련 강의 영상에서 사용한 소스코드 버전 문제로 에러 발생

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

Link(클래스) - of(정적 메서드) 호출하여 문제 해결

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

3-7.테스트용 DB 와 설정 분리하기


docker postgres image 올리고 어플리케이션에 적용
test/resources 에 application-test.properties 생성하고 H2 DB 설정
Test 클래스의 클래스 레벨에 @ActiveProfiles("test") 설정하여
중복되는 설정만 test properties 를 반영하고 나머지는 main 에 있는 properties 설정들 그대로 적용
특별한 내용 없어서 구체적인 내용은 깃헙

3-8.API 인덱스 만들기


다른 리소스에 대한 진입 링크를 제공하는 인덱스 생성

Index Controller 추가

@RestController
public class IndexController {
    @GetMapping("/api")
    public RepresentationModel index() {
        var index = new RepresentationModel();
        index.add(linkTo(EventController.class).withRel("/events"));
        return index;
    }
}

Index Test Controller 추가

//... 주석 생략
public class IndexControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    public void index() throws Exception {
        this.mockMvc.perform(get("/api"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("_links.events").exists());
    }
}

Error Resource 클래스 추가

public class ErrorsResource extends EntityModel<Errors> {
    public ErrorsResource(Errors errors) {
        super(errors);
        add(linkTo(methodOn(IndexController.class).index()).withRel("index"));
    }
}

EventController 코드 수정

ResponseEntity -> body 에 넣었던 Errors 객체를 ErrorsResource 객체에 넣고 링크포함 하여 body 에 전달
동일하게 사용되는 코드를 메서드로 분리하는 리팩토링 작업 진행

@PostMapping
public ResponseEntity createEvent(@RequestBody @Validated 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(); //free, offline 설정

    ...생략
}

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

이벤트 테스트에 코드 추가

@Test
@DisplayName("입력 값이 잘못된 경우에 에러가 발생하는 테스트")
public void createEvent_bad_request_wrong_valid() throws Exception {
	//...EventDto 빌더 생략

    mockMvc.perform(post("/api/events")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(eventDto)))
            //... 생략
            //추가 코드
            .andExpect(jsonPath("_links.index").exists());
}

인덱스 작업 후 이벤트 테스트 실행할 때 이슈 발생
jackson 라이브러리 버전문제로 Spring Boot 2.3 버전부터 jackson 은
json 을 생성할 때 Array 부터 생성하는 걸 허용하지 않음
이벤트 적용 전 후 json 데이터

before

[
  {
    "field":"basePrice",
    "objectName":"eventDto",
    "code":"wrongValue",
    ...
  },
  {
    "field":"maxPrice",
    "objectName":"eventDto","code"
    ...
  }
]

after

{
  "errors":[
  	{
      "field":"basePrice",
      "objectName":"eventDto"
      ...
    },
    {
      "field":"maxPrice",
      "objectName":"eventDto"
    }
  ],
  "_links":{
    "index":{
      "href":"http://localhost:8080/api"
    }
  }
}
profile
일상을 담는 백엔드 코드몽키

0개의 댓글