2021년 8월 17일 화요일

토이프로젝트 API 개선하기 (REST 하게..)

 

  • 토이프로젝트에 생성한 api는 REST 하지 못한 것 같다. (단순 json 응답임)

    • REST하게 개선해보자 (백기선님의 REST API 강의 참고)

      // 기존 소스
      @RequiredArgsConstructor
      @RestController
      @Slf4j
      @RequestMapping(value = "/api/board")
      public class BoardApiController {
          private final BoardService boardService;
      
      
              @PostMapping
              public ResponseEntity addBoard(@RequestBody BoardRequestDto boardRequestDto, @LoginUser SessionUser user) {
                  Map<String, Object> resultMap = new HashMap<>();
      
      
                  Long result = boardService.save(boardRequestDto, user.getEmail());
      
      
                  resultMap.put("result", result);
                  resultMap.put("resultMessage", "success");
      
      
                  return new ResponseEntity<>(resultMap, HttpStatus.OK);
              }
              ...
      }
      

REST API

  • REpresentational State Transfer(REST) + Application Programming Interface(API)

  • 서로 다른 시스템간의 독립적인 진화를 위함

  • REST 아키텍처 스타일을 따르는 API

    • Client-Server, Cache, Stateless, Layered System, ...

    • Uniform Interface : URL로 지정된 리소스에 대한 조작을 통일하고 한정된 인터페이스로 수행하는 아키텍쳐 스타일

      • Indentification of resources, ...

      • Hypermedia as the Engine of Application State(HATEOAS)

        • 하이퍼미디어(링크)를 통해 애플리케이션 상태 변화가 가능해야한다.

        • 링크 정보를 동적으로 바꿀 수 있다.(Versioning 할 필요 없이!)

        • 응답을 받은 다음에 다음 애플리케이션 상태로 전이를 하려면 서버가 보내준 응답에 들어있는 링크정보를 사용해서 이동을 해야한다.

        • HATEOAS 로 만들려면?

          • HAL을 통해 링크를 정의 (다른 방법도 있음)
      • self-descriptive message

        • 메시지 스스로 메시지에 대한 설명이 가능해야함

        • 서버가 변해도 클라이언트는 그 메시지를 보고 해석이 가능하다

        • 확장 가능한 커뮤니케이션

        • self-descriptive message로 만들려면?

          • HAL의 링크 데이터에 profile 링크 추가 (다른 방법도 있음)

HAL ?

  • Hypertext Application Language

  • API의 리소스 간에 일관되고 쉬운 하이퍼링크 방법을 제공하는 간단한 형식

  • REST API에 HAL을 포함하면 기본적으로 자체 문서화될 뿐만 아니라 사용자가 훨씬 더 쉽게 탐색할 수 있음

Spring HATEOAS ?

  • 스프링 프로젝트 중 하나로 주요 목적은 API를 만들 때 리소스를 REST 하게 쉽게 제공해주기 위한 툴을 편리하게 사용할 수 있게 해주는 라이브러리

  • 하이퍼미디어를 사용해서 클라이언트가 애플리케이션 서바와 동적으로 정보를 주고 받을 수 있는 방법

  • 여러 기능을 제공하는데 가장 중요한 기능은 링크를 만드는 기능, 리소스(응답+링크)를 만드는 기능임

    • 링크
      • HREF
      • REL (현재 릴레이션과의 관계)
        • self
        • profile
        • ... (조회, 업데이트 등 링크)

Spring Rest Docs ?

  • Spring MVC Test를 사용해서 REST API 문서의 조각들을 생성하는데 유용한 툴

    • 테스트에 사용된 정보가 문서로 생성됨
  • 이 문서 조각을 모아서 REST API 가이드 문서를 생성할 수 있음 (html)


실습

Spring HATEOAS

  • 백기선님의 REST API 강의에서는 Resource 사용하였지만 Spring HATEOAS 버전 변경에 따라 이름이 변경됨

  • Resource를 사용하여 구성한 body를 EntityModel 으로 바꿔보자

    • 강의 예제 소스

      public class EventResource extends Resource<Event> {
          public EventResource(Event event, Link... links){
              super(event, links);
              add(linkTo(EventController.class).slash(event.getId()).withSelfRel());
          }
      }
      
      
      ...
      
      
      @PostMapping
      public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto,
                                        Errors errors, @CurrentUser Account currentUser){
      
      
          // @Valid를 통해 검증한 에러들을 errors에 담고 검증하여 badRequest
          if(errors.hasErrors()){
              return badRequest(errors);
          }
          // validate를 통해 검증한 에러들을 errors에 담고 검증하여 badRequest
          eventValidator.validate(eventDto, errors);
          if(errors.hasErrors()){
              return badRequest(errors);
          }
      
      
          Event event = modelMapper.map(eventDto, Event.class);
          event.update();
          event.setManager(currentUser);
      
      
          Event newEvent = this.eventRepository.save(event);
          ControllerLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(newEvent.getId());
          URI createUri = selfLinkBuilder.toUri();
          EventResource eventResource  = new EventResource(event);
          eventResoruce.add(linkTo(EventController.class).withRel("query-events"));
          eventResoruce.add(selfLinkBuilder.withRel("update-event"));
          eventResoruce.add(new Link("/docs/index.html#resources-events-create").withRel("profile"));
      
      
          return ResponseEntity.created(createUri).body(eventResource);
      }
      
    • 예제를 참고하여 개선한 토이프로젝트 소스

      @PostMapping
      public ResponseEntity addBoard(@RequestBody BoardRequestDto boardRequestDto, @LoginUser SessionUser user, Errors errors) {
      
      
          boardValidator.validate(boardRequestDto, errors);
          if(errors.hasErrors()){
              log.debug("잘못된 요청입니다. error: " + errors.getFieldError());
              return ResponseEntity.badRequest().body(CollectionModel.of(errors.getAllErrors()));
          }
      
      
          Long result = boardService.save(boardRequestDto, user.getEmail());
          boardRequestDto.setId(result);
      
      
          WebMvcLinkBuilder webMvcLinkBuilder = linkTo(BoardApiController.class).slash(result);
      
      
          // 링크 제공
          EntityModel<BoardRequestDto> entityModel = EntityModel.of(boardRequestDto);
          // profile
          entityModel.add(linkTo(IndexController.class).slash("/docs/index.html#resources-add-board").withRel("profile"));
          // self
          entityModel.add(webMvcLinkBuilder.withSelfRel());
          // update
          entityModel.add(webMvcLinkBuilder.withRel("update-board"));
      
      
          return ResponseEntity.created(webMvcLinkBuilder.toUri()).body(entityModel);
      }
      
    • response body

      • _links 를 통해 다양한 상태의 링크를 서버가 제공한다. (HATEOAS)
      {
          "id":30,
          "boardName":"boardName1",
          "delFlag":"N",
          "_links":{
              "profile":{
                  "href":"http://localhost:8080/docs/index.html#resources-add-board"
              },
              "self":{
                  "href":"http://localhost:8080/api/board/30"
              },
              "update-board":{
                  "href":"http://localhost:8080/api/board/30"
              }
          }
      }
      

Spring Rest Docs

  • 문서조각 만들기

    • build.gradle 에 다음을 추가하고 test 코드에 document 관련 코드를 추가하면 문서 조각이 생성된다.

      dependencies {
              ...
              testImplementation('org.springframework.restdocs:spring-restdocs-mockmvc')
              ...
      }
      
      @SpringBootTest
      @Transactional
      @AutoConfigureMockMvc
      @AutoConfigureRestDocs
      @ActiveProfiles("test")
      class BoardApiControllerTest {
      
      
      @Test
      @DisplayName("보드 등록 api 테스트")
      public void addBoardApiTest() throws Exception{
          //given
          BoardRequestDto boardRequestDto = new BoardRequestDto("boardName1");
      
      
          User user = new User("testUser1", "email", "picture", Role.ADMIN);
          userRepository.save(user);
      
      
          // 세션 정보
          SessionUser sessionUser = new SessionUser(user);
          mockHttpSession = new MockHttpSession();
          mockHttpSession.setAttribute("user", sessionUser);
      
      
          //when then
          mockMvc.perform(
                  post("/api/board")
                          .session(mockHttpSession)
                          .contentType(MediaType.APPLICATION_JSON_UTF8)
                          .accept(MediaTypes.HAL_JSON)
                          .content(objectMapper.writeValueAsString(boardRequestDto))
          )
                  .andDo(print())
                  .andExpect(status().is2xxSuccessful())
                  .andExpect(jsonPath("_links.update-board").exists())
                  .andDo(document("create-board", 
                          links(
                                  linkWithRel("profile").description("link to profile"),
                                  linkWithRel("self").description("link to self"),
                                  linkWithRel("update-board").description("link to update an existing board")
                              ),
                          relaxedResponseFields(
                                  fieldWithPath("id").description("identifier of new Board"),
                                  fieldWithPath("boardName").description("name of new Board"),
                                  fieldWithPath("delFlag").description("delFlag of new Board")
                                  )
                          ));
           }
      }
      
    • 테스트 코드 실행 후 생성된 adoc 파일들 (문서조각)



  • 문서 만들기

    • gradle에 맞는 위치에 종합할 문서 파일을 직접 생성한다.(src/docs/asciidoc) https://spring.io/guides/gs/testing-restdocs/

      The default location for Asciidoctor sources in Gradle is src/doc/asciidoc
      
      • 아까 생성한 조각을 사용할 수 있다!

        include::{snippets}/create-board/curl-request.adoc[]
        


    • 생성한 index.adoc 파일을 html로 변환시켜야한다!! 여러 사이트를 참고해서 build.gradle 을 수정해보자

      • https://github.com/spring-projects/spring-restdocs/blob/main/samples/rest-notes-spring-hateoas/build.gradle

      • https://spring.io/guides/gs/testing-restdocs/

      • https://shinsunyoung.tistory.com/85

        plugins {
            id 'org.springframework.boot' version '2.3.8.RELEASE'
            id 'io.spring.dependency-management' version '1.0.11.RELEASE'
            id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
            id 'org.asciidoctor.convert' version '1.5.3' // 추가
            id 'java'
        }
        
        
        group = 'com.toy'
        version = '0.0.1-SNAPSHOT'
        sourceCompatibility = '11'
        
        
        repositories {
            mavenCentral()
            maven { url "https://plugins.gradle.org/m2/" }
        }
        
        
        subprojects {
            group = 'com.toy'
            version = '0.0.1-SNAPSHOT'
        
        
            apply plugin: 'java'
            apply plugin: 'org.springframework.boot'
            apply plugin: 'io.spring.dependency-management'
            apply plugin: 'com.ewerk.gradle.plugins.querydsl'
            apply plugin: 'org.asciidoctor.convert' // 추가
        
        
            sourceCompatibility = 11
        
        
            repositories {
                mavenCentral()
            }
        
        
            dependencies {
                asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor' // 추가
                compileOnly 'org.projectlombok:lombok'
                annotationProcessor 'org.projectlombok:lombok'
                compile("org.mariadb.jdbc:mariadb-java-client")
        
        
                testImplementation('org.springframework.boot:spring-boot-starter-test') {
                    exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
                }
                testImplementation('org.springframework.restdocs:spring-restdocs-mockmvc')
                testImplementation 'org.springframework.security:spring-security-test'
                developmentOnly 'org.springframework.boot:spring-boot-devtools'
            }
        
        
                // 추가
            ext {
                snippetsDir = "${buildDir}/generated-snippets"
            }
        
        
            configurations {
                compileOnly {
                    extendsFrom annotationProcessor
                }
            }
        
        
            test {
                outputs.dir snippetsDir // 추가
                useJUnitPlatform()
            }
        
        
            // 추가
            asciidoctor {
                inputs.dir snippetsDir
                dependsOn test
            }
        
        
            def querydslDir = "$buildDir/generated/querydsl"
        
        
            querydsl {
                library = "com.querydsl:querydsl-apt"
                jpa = true
                querydslSourcesDir = querydslDir
            }
            sourceSets {
                main {
                    java {
                        srcDirs = ['src/main/java', querydslDir]
                    }
                }
            }
            compileQuerydsl{
                options.annotationProcessorPath = configurations.querydsl
            }
            configurations {
                querydsl.extendsFrom compileClasspath
            }
        
        
        }
        
        
        project(':module-web') {
            dependencies {
                implementation project(path: ':module-domain', configuration: 'default')
            }
        
        
            // 추가
            task copyDocument(type: Copy) {
                dependsOn asciidoctor
        
        
                from file("build/asciidoc/html5/")
                into file("src/main/resources/static/docs")
            }
        
        
            // 추가
            build {
                dependsOn copyDocument
            }
        }
        
        
        project(':module-batch') {
            dependencies {
                implementation project(path: ':module-domain', configuration: 'default')
            }
        }
        
        
        bootJar { enabled = false }
        
      • gradle build 후 다음 경로에 html 파일이 생성된 것을 볼 수 있으며 profile에 해당 문서를 링크하면 API에 대한 정보, 명세 등을 볼 수 있고, 스스로 설명할 수 있다! (self-descriptive message)


                    

            
[간단히 생성한 html 파일]