토이프로젝트에 생성한 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 버전 변경에 따라 이름이 변경됨
ResourceSupport is now RepresentationModel Resource is now EntityModel Resources is now CollectionModel PagedResources is now PagedModel
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://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)