중급자를 위해 준비한
[백엔드] 강의입니다.
다양한 스프링 기술을 사용하여 Self-Descriptive Message와 HATEOAS(Hypermedia as the engine of application state)를 만족하는 REST API를 개발하는 강의입니다.
✍️
이런 걸
배워요!
Self-Describtive Message와 HATEOAS를 만족하는 REST API를 이해
다양한 스프링 기술을 활용하여 REST API 개발
스프링 HATEOAS와 스프링 REST Docs 프로젝트 활용
테스트 주도 개발(TDD)
스프링으로 REST를 따르는 API를 만들어보자!
백기선의 스프링 기반 REST API 개발
스프링 기반 REST API 개발
이 강의에서는 다양한 스프링 기술을 사용하여 Self-Descriptive Message와 HATEOAS(Hypermedia as the engine of application state)를 만족하는 REST API를 개발합니다.
그런 REST API로 괜찮은가
2017년 네이버가 주관한 개발자 컨퍼런스 Deview에서 그런 REST API로 괜찮은가라는 이응준님의 발표가 있었습니다. 현재 REST API로 불리는 대부분의 API가 실제로는 로이 필딩이 정의한 REST를 따르고 있지 않으며, 그 중에서도 특히 Self-Descriptive Message와 HATEOAS가 지켜지지 않음을 지적했고, 그에 대한 대안을 제시되었습니다.
이번 강의는 해당 발표에 영감을 얻어 만들어졌습니다. 2018년 11월에 KSUG에서 동일한 이름으로 세미나를 진행한 경험이 있습니다. 4시간이라는 짧지 않은 발표였지만, 빠르게 진행하느라 충분히 설명하지 못하고 넘어갔던 부분이 있었습니다. 내용을 더 보충하고, 또 해결하려는 문제에 대한 여러 선택지를 제공하는 것이 좋을 것 같아 이 강의를 만들게 되었습니다.
또한 이 강의에서는 제가 주로 사용하는 IntelliJ 단축키도 함께 설명하고 있습니다.
인프런 :
강의 : https://www.inflearn.com/course/spring_rest-api#
강의 자료 : https://docs.google.com/document/d/1GFo3W6XxqhxDVVqxiSEtqkuVCX93Tdb3xzINRtTIx10/edit
강의 소스 :
https://gitlab.com/whiteship/natural
https://github.com/keesun/study
ksug201811restapi
이번 강좌에서는 다음의 다양한 스프링 기술을 사용하여 REST API를 개발합니다.
스프링 프레임워크
스프링 부트
스프링 데이터 JPA
스프링 HATEOAS
스프링 REST Docs
스프링 시큐리티 OAuth2
또한 개발은 테스트 주도 개발(TDD)로 진행하기 때문에 평소 테스트 또는 TDD에 관심있던 개발자에게도 이번 강좌가 도움이 될 것으로 기대합니다.
사전 학습
스프링 프레임워크 핵심 기술 (필수)
스프링 부트 개념과 활용 (필수)
스프링 데이터 JPA (선택)
학습 목표
Self-Describtive Message와 HATEOAS를 만족하는 REST API를 이해합니다.
다양한 스프링 기술을 활용하여 REST API를 개발할 수 있습니다.
스프링 HATEOAS와 스프링 REST Docs 프로젝트를 활용할 수 있습니다.
테스트 주도 개발(TDD)에 익숙해 집니다.
REST API 는 다음 두가지를 만족해야 한다.
1) Self-Describtive Message
2) HATEOAS
참조 :
1) 스프링 기반 REST API 개발
https://tram-devlog.tistory.com/entry/스프링-기반-REST-API-개발-KSUG-세미나
2)에러 발상 처리
https://acet.pe.kr/924
[3] HATEOAS와 Self-Describtive Message 적용
19. 스프링 HATEOAS 소개
강의 :
https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16426&tab=curriculum
스프링 HATEOAS
https://docs.spring.io/spring-hateoas/docs/current/reference/html/
링크 만드는 기능
문자열 가지고 만들기
컨트롤러와 메소드로 만들기
리소스 만드는 기능
리소스: 데이터 + 링크
링크 찾아주는 기능
Traverson
LinkDiscoverers
링크
HREF
REL
self
profile
update-event
query-events
20. 스프링 HATEOAS 적용
강의 :
https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16427&tab=curriculum
버전 업
EventController
import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.MediaTypes; import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.validation.Errors; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import java.net.URI; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; @Controller @RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE) @RequiredArgsConstructor @Log4j2 public class EventController { private final EventRepository eventRepository; // private final ModelMapper modelMapper; private final EventValidator eventValidator; @PostMapping public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors){ if(errors.hasErrors()){ return ResponseEntity.badRequest().body(errors); } //커스텀 validate 검사 eventValidator.validate(eventDto, errors); if(errors.hasErrors()){ return ResponseEntity.badRequest().body(errors); } //modelMapper 오류 //Event event=modelMapper.map(eventDto, Event.class); Event event = eventDto.toEvent(); Integer eventId = event.getId(); //유료인지 무료인지 변경처리 event.update(); Event newEvent=this.eventRepository.save(event);//저장 /** * * ★ 링크 생성하기 * EntityModel.of(newEvent); Resource 객체를 가져와서 사용 * * **/ WebMvcLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(eventId); URI createdUri = selfLinkBuilder.toUri(); log.info("* createdUri {} " , createdUri); //출력 => * createdUri http://localhost/api/events EntityModel eventResource = EntityModel.of(newEvent); //셀프링크 추가 방법 eventResource.add(linkTo(EventController.class).slash(eventId).withSelfRel()); //1)링크추가방법 eventResource.add(linkTo(EventController.class).withRel("query-events")); //2)링크추가방법 eventResource.add(selfLinkBuilder.withRel("update-event")); return ResponseEntity.created(createdUri).body(eventResource); } ~
package net.macaronics.restapi.events; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.hateoas.MediaTypes; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDateTime; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest @AutoConfigureMockMvc public class EventControllerTests { @Test @DisplayName("정상적으로 이벤트를 생성하는 테스트") public void createEvent() throws Exception{ Event event =Event.builder() .id(100) .name("Spring") .description("REST API Development with Spring") .beginEnrollmentDateTime(LocalDateTime.of(2023, 05, 06, 19 , 20 )) .closeEnrollmentDateTime(LocalDateTime.of(2023, 05, 20, 20 , 20)) .beginEventDateTime(LocalDateTime.of(2023, 05, 20, 20, 20)) .endEventDateTime(LocalDateTime.of(2023, 11, 26, 20, 20)) .basePrice(100) .maxPrice(200) .limitOfEnrollment(100) .location("강남역 D2 스타텀 팩토리") .free(false) .offline(false) .eventStatus(EventStatus.DRAFT) .build(); mockMvc.perform( post("/api/events") .contentType(MediaType.APPLICATION_JSON_UTF8) .accept(MediaTypes.HAL_JSON) .content(objectMapper.writeValueAsString(event)) ) .andDo(print()) .andExpect(status().isCreated()) .andExpect(jsonPath("id").exists()) .andExpect(header().exists(HttpHeaders.LOCATION)) //.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE)) .andExpect(header().string(HttpHeaders.CONTENT_TYPE, "application/hal+json;charset=UTF-8")) .andExpect(jsonPath("free").value(false)) .andExpect(jsonPath("offline").value(true)) // .andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.toString())) .andExpect(jsonPath("_links.self").exists()) .andExpect(jsonPath("_links.query-events").exists()) .andExpect(jsonPath("_links.update-event").exists()); } ~
한글 깨짐
application.properties
# REST API 에서 한글깨짐으로 인한 UTF-8 세팅 server.servlet.encoding.charset=UTF-8 server.servlet.encoding.force=true
EntityModel 사용방법 예
https://www.inflearn.com/questions/203512/entitymodel-deprecated-어떻게-바꾸면-될까요
// 전체 사용자 목록 @GetMapping("/users2") public ResponseEntity<CollectionModel<EntityModel<User>>> retrieveUserList2() { List<EntityModel<User>> result = new ArrayList<>(); List<User> users = service.findAll(); for (User user : users) { EntityModel entityModel = EntityModel.of(user); entityModel.add(linkTo(methodOn(this.getClass()).retrieveAllUsers()).withSelfRel()); result.add(entityModel); } return ResponseEntity.ok(CollectionModel.of(result, linkTo(methodOn(this.getClass()).retrieveAllUsers()).withSelfRel())); }
21. 스프링 REST Docs 소개
강의 :
https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16428&tab=curriculum
★ => Spring REST Docs 적용 및 최적화 하기
https://docs.spring.io/spring-restdocs/docs/2.0.2.RELEASE/reference/html5/
REST Docs 코딩
andDo(document(“doc-name”, snippets))
snippets
links()
requestParameters() + parameterWithName()
pathParameters() + parametersWithName()
requestParts() + partWithname()
requestPartBody()
requestPartFields()
requestHeaders() + headerWithName()
requestFields() + fieldWithPath()
responseHeaders() + headerWithName()
responseFields() + fieldWithPath()
...
Relaxed*
Processor
preprocessRequest(prettyPrint())
preprocessResponse(prettyPrint())
...
Constraint
22. 스프링 REST Docs 적용
강의 :
https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16429&tab=curriculum
REST Docs 자동 설정
@AutoConfigureRestDocs
RestDocMockMvc 커스터마이징
RestDocsMockMvcConfigurationCustomizer 구현한 빈 등록
@TestConfiguration
테스트 할 것
API 문서 만들기
요청 본문 문서화
응답 본문 문서화
링크 문서화
profile 링크 추가
응답 헤더 문서화
1)라이브러리 추가
<dependency> <groupId>org.springframework.restdocs</groupId> <artifactId>spring-restdocs-mockmvc</artifactId> <scope>test</scope> </dependency>
2) 테스트 코드에 @AutoConfigureRestDocs 어노테이션 추가 및 마지막에 .andDo(document("create-event")); 추가
@SpringBootTest @AutoConfigureMockMvc @AutoConfigureRestDocs @Import(RestDocsConfiguration.class) public class EventControllerTests { @Test @DisplayName("정상적으로 이벤트를 생성하는 테스트") public void createEvent() throws Exception{ Event event =Event.builder() .id(100) .name("Spring") .description("REST API Development with Spring") .beginEnrollmentDateTime(LocalDateTime.of(2023, 05, 06, 19 , 20 )) .closeEnrollmentDateTime(LocalDateTime.of(2023, 05, 20, 20 , 20)) .beginEventDateTime(LocalDateTime.of(2023, 05, 20, 20, 20)) .endEventDateTime(LocalDateTime.of(2023, 11, 26, 20, 20)) .basePrice(100) .maxPrice(200) .limitOfEnrollment(100) .location("강남역 D2 스타텀 팩토리") .free(false) .offline(false) .eventStatus(EventStatus.DRAFT) .build(); mockMvc.perform( post("/api/events") .contentType(MediaType.APPLICATION_JSON_UTF8) .accept(MediaTypes.HAL_JSON) .content(objectMapper.writeValueAsString(event)) ) .andDo(print()) .andExpect(status().isCreated()) .andExpect(jsonPath("id").exists()) .andExpect(header().exists(HttpHeaders.LOCATION)) //.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE)) .andExpect(header().string(HttpHeaders.CONTENT_TYPE, "application/hal+json;charset=UTF-8")) .andExpect(jsonPath("free").value(false)) .andExpect(jsonPath("offline").value(true)) // .andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.toString())) .andExpect(jsonPath("_links.self").exists()) .andExpect(jsonPath("_links.query-events").exists()) .andExpect(jsonPath("_links.update-event").exists()) //스프링 REST Docs 적용 .andDo(document("create-event")); }
3) 마지막에 추가한다.
//스프링 REST Docs 적용 .andDo(document("create-event"));
테스트를 실행하면 target 디렉토리의 generated-snippets 에 파일들이 다음과 같이 생성된다.
정렬이 안되어 있어 보기 어려운데 다음과 같이 RestDocsConfiguration 을 추가하면 자동으로 정렬 처리 된다.
4) RestDocsConfiguration
package net.macaronics.restapi.common; import org.springframework.boot.test.autoconfigure.restdocs.RestDocsMockMvcConfigurationCustomizer; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; /** * 스프링 REST Docs 적용후 formatting */ @TestConfiguration public class RestDocsConfiguration { @Bean public RestDocsMockMvcConfigurationCustomizer restDocsMockMvcConfigurationCustomizer(){ return configurer->configurer.operationPreprocessors() .withRequestDefaults(prettyPrint()) .withResponseDefaults(prettyPrint()); } }
테스트 클래스에 다음과 같이 임포트 후 사용
@SpringBootTest @AutoConfigureMockMvc @AutoConfigureRestDocs @Import(RestDocsConfiguration.class) public class EventControllerTests {
23. 스프링 REST Docs 각종 문서 조각 생성하기
스프링 REST Docs: 링크, (Req, Res) 필드와 헤더 문서화
강의 :
https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16430&tab=curriculum
요청 필드 문서화
requestFields() + fieldWithPath()
responseFields() + fieldWithPath()
requestHeaders() + headerWithName()
responseHedaers() + headerWithName()
links() + linkWithRel()
테스트 할 것
Relaxed 접두어
장점: 문서 일부분만 테스트 할 수 있다.
단점: 정확한 문서를 생성하지 못한다.
다음과 같이 requestHeaders , requestFields , responseHeaders ,responseFields 추가하면은 아래 이미지와 같이 파일들 추가 생성된다.
이때 주의 할점은 요청 파라미터 및 응답파리미터와 와 동일하게 빠짐없이 작성해 줘야 오류가 발행하지 않으며,
만약에 생략을 할 경우에는 relaxedR 를 붙여 줘서 적용 해야 한다.
//스프링 REST Docs 적용 .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 an existing event") ), requestHeaders( headerWithName(HttpHeaders.ACCEPT).description("accept header"), headerWithName(HttpHeaders.CONTENT_TYPE).description("content type header") ), //주의 : 요청 파라미터와 동일하게 빠짐없이 작성해 줘야 한다. requestFields( fieldWithPath("id").description("id of new Event"), fieldWithPath("beginEnrollmentDateTime").description("beginEnrollmentDateTime of new event"), fieldWithPath("name").description("name of new Enrollment"), fieldWithPath("description").description("ddescription 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 Enrollment") ), responseHeaders( headerWithName(HttpHeaders.LOCATION).description("Location header"), headerWithName(HttpHeaders.CONTENT_TYPE).description("Content type") ), // relaxedResponseFields 응답의 일부분만 해당하는 자료 문서화 responseFields( fieldWithPath("id").description("id of new Event"), fieldWithPath("beginEnrollmentDateTime").description("beginEnrollmentDateTime of new event"), fieldWithPath("name").description("name of new Enrollment"), fieldWithPath("description").description("ddescription 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 Enrollment"), fieldWithPath("_links.self.href").description("link to self"), fieldWithPath("_links.query-events.href").description("link to query event list"), fieldWithPath("_links.update-event.href").description("link to update existing event") ) ));
25. 스프링 REST Docs 문서빌드
강의 :
https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16431&tab=curriculum
현재 문서
참조 :
https://docs.spring.io/spring-restdocs/docs/current/reference/htmlsingle/#getting-started
1) 스프링부트 3.0 이상 - 다음plugin 코드를 추가한다.
<!-- 추가--> <plugin> <groupId>org.asciidoctor</groupId> <artifactId>asciidoctor-maven-plugin</artifactId> <version>2.2.1</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>3.0.0</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>
2) 이미지와 같이 디렉토리 위치해(디렉토리 생성할것) index.adoc 파일을 생성해 준다.
src/docs/asciidoc/index.adoc
3) index.adoc 파일 내용
= Natural REST API Guide 백기선; :doctype: book :icons: font :source-highlighter: highlightjs :toc: left :toclevels: 4 :sectlinks: :operation-curl-request-title: Example request :operation-http-response-title: Example response [[overview]] = 개요 [[overview-http-verbs]] == HTTP 동사 본 REST API에서 사용하는 HTTP 동사(verbs)는 가능한한 표준 HTTP와 REST 규약을 따릅니다. |=== | 동사 | 용례 | `GET` | 리소스를 가져올 때 사용 | `POST` | 새 리소스를 만들 때 사용 | `PUT` | 기존 리소스를 수정할 때 사용 | `PATCH` | 기존 리소스의 일부를 수정할 때 사용 | `DELETE` | 기존 리소스를 삭제할 떄 사용 |=== [[overview-http-status-codes]] == HTTP 상태 코드 본 REST API에서 사용하는 HTTP 상태 코드는 가능한한 표준 HTTP와 REST 규약을 따릅니다. |=== | 상태 코드 | 용례 | `200 OK` | 요청을 성공적으로 처리함 | `201 Created` | 새 리소스를 성공적으로 생성함. 응답의 `Location` 헤더에 해당 리소스의 URI가 담겨있다. | `204 No Content` | 기존 리소스를 성공적으로 수정함. | `400 Bad Request` | 잘못된 요청을 보낸 경우. 응답 본문에 더 오류에 대한 정보가 담겨있다. | `404 Not Found` | 요청한 리소스가 없음. |=== [[overview-errors]] == 오류 에러 응답이 발생했을 때 (상태 코드 >= 400), 본문에 해당 문제를 기술한 JSON 객체가 담겨있다. 에러 객체는 다음의 구조를 따른다. include::{snippets}/errors/response-fields.adoc[] 예를 들어, 잘못된 요청으로 이벤트를 만들려고 했을 때 다음과 같은 `400 Bad Request` 응답을 받는다. include::{snippets}/errors/http-response.adoc[] [[overview-hypermedia]] == 하이퍼미디어 본 REST API는 하이퍼미디어와 사용하며 응답에 담겨있는 리소스는 다른 리소스에 대한 링크를 가지고 있다. 응답은 http://stateless.co/hal_specification.html[Hypertext Application from resource to resource. Language (HAL)] 형식을 따른다. 링크는 `_links`라는 키로 제공한다. 본 API의 사용자(클라이언트)는 URI를 직접 생성하지 않아야 하며, 리소스에서 제공하는 링크를 사용해야 한다. [[resources]] = 리소스 [[resources-index]] == 인덱스 인덱스는 서비스 진입점을 제공한다. [[resources-index-access]] === 인덱스 조회 `GET` 요청을 사용하여 인덱스에 접근할 수 있다. operation::index[snippets='response-body,http-response,links'] [[resources-events]] == 이벤트 이벤트 리소스는 이벤트를 만들거나 조회할 때 사용한다. [[resources-events-list]] === 이벤트 목록 조회 `GET` 요청을 사용하여 서비스의 모든 이벤트를 조회할 수 있다. operation::get-events[snippets='response-fields,curl-request,http-response,links'] [[resources-events-create]] === 이벤트 생성 `POST` 요청을 사용해서 새 이벤트를 만들 수 있다. operation::create-event[snippets='request-fields,curl-request,http-response,links'] [[resources-events-get]] === 이벤트 조회 `Get` 요청을 사용해서 기존 이벤트 하나를 조회할 수 있다. operation::get-event[snippets='request-fields,curl-request,http-response,links'] [[resources-events-update]] === 이벤트 수정 `PUT` 요청을 사용해서 기존 이벤트를 수정할 수 있다. operation::update-event[snippets='request-fields,curl-request,http-response,links']
4) 오른쪽 maven 메뉴에서 package 클릭후 빌드하면 끝
5) 빌드에 성공하면 target 디렉토리에 generated-docs 디렉토리가 생성 되며 index.html 파일 생성되게 된다.
index.html 파일 선택후 마우스 우클릭후 해당 디렉토리 경로 복사후 브라우저창에서 열면
rest api 문서가 생성된것을 볼수 있다.
file:///F:/Study/JAVA/%EB%B0%B1%EA%B8%B0%EC%84%A0/RestAPI/rest-api-width-spring/target/generated-docs/index.html#resources-events-update_request_fields
다음과 같이 간단하게 API 문서가 생성 된다.
http://localhost:8080/docs/index.html
6) profile 링크 추가
EventController 에서 profile 링크 추가
eventResource.add(Link.of("/docs/index.html#resource-events-create").withRel("profile"));
7)테스트 코드에서 추가 후 빌드하면 진정한 REST API 개발 문서 완료
linkWithRel("profile").description("link to update an existing event") responseFields( fieldWithPath("_links.profile.href").description("link to profile event") )
깃 소스
26. 테스트용 DB와 설정 분리하기
강의 :
Scripts
Here, I memo scripts that I have used during development.
Postgres
Run Postgres Container
docker run --name ndb -p 5432:5432 -e POSTGRES_PASSWORD=pass -d postgres
This cmdlet will create Postgres instance so that you can connect to a database with:
- database: postgres
- username: postgres
- password: pass
- post: 5432
Getting into the Postgres container
docker exec -i -t ndb bash
Then you will see the containers bash as a root user.
Connect to a database
psql -d postgres -U postgres
Query Databases
\l
Query Tables
\dt
Quit
\q
application.properties
Datasource
spring.datasource.username=postgres spring.datasource.password=pass spring.datasource.url=jdbc:postgresql://localhost:5432/postgres spring.datasource.driver-class-name=org.postgresql.Driver
Hibernate
spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true spring.jpa.properties.hibernate.format_sql=true logging.level.org.hibernate.SQL=DEBUG logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
Test Database
spring.datasource.username=sa spring.datasource.password= spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.driver-class-name=org.h2.Driver
애플리케이션 설정과 테스트 설정 중복 어떻게 줄일 것인가?
프로파일과 @ActiveProfiles 활용
application-test.properties
spring.datasource.username=sa spring.datasource.password= spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.driver-class-name=org.h2.Driver spring.datasource.hikari.jdbc-url=jdbc:h2:mem:testdb spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
test 디렉토리에 다음 이미지처럼 resource 폴더를 생성후
application-test.properties 파일을 생성해 준다.
인텔리제이에서 인식해 줄수 있도록 다음과 같이 설정해 준다.
테스트 클래스에 어노테이션 추가
@ActiveProfiles("test") public class EventControllerTests {
27. API 인덱스 만들기
강의 :
https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16433&tab=curriculum
1) IndexController 을 다음과 같이 생성해 준다.
IndexController
package net.macaronics.restapi.index; import net.macaronics.restapi.events.EventController; import org.springframework.hateoas.RepresentationModel; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; @RestController public class IndexController { @GetMapping("/api") public RepresentationModel index(){ RepresentationModel index=new RepresentationModel(); index.add(linkTo(EventController.class).withRel("events")); return index; } }
2) IndexControllerTest 생성
package net.macaronics.restapi.index; import com.fasterxml.jackson.databind.ObjectMapper; import net.macaronics.restapi.common.RestDocsConfiguration; import net.macaronics.restapi.events.Event; import net.macaronics.restapi.events.EventStatus; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.hateoas.MediaTypes; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import java.time.LocalDateTime; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc @AutoConfigureRestDocs @Import(RestDocsConfiguration.class) @ActiveProfiles("test") public class IndexControllerTest { @Autowired MockMvc mockMvc; @Autowired ObjectMapper objectMapper; @Test public void index() throws Exception{ this.mockMvc.perform( get("/api") .contentType(MediaType.APPLICATION_JSON_UTF8) .accept(MediaTypes.HAL_JSON) ).andExpect(status().isOk()) .andDo(print()) .andExpect(jsonPath("_links.events").exists()); } @Test @DisplayName("인덱스 입력 값이 잘못된 경우에 에러가 발생하는 테스트") public void createEvent_Bad_Request_Wrong_Input() throws Exception{ Event event =Event.builder() .name("Spring") .description("REST API Development with Spring") .beginEnrollmentDateTime(LocalDateTime.of(2023, 05, 06, 19 , 20 )) .closeEnrollmentDateTime(LocalDateTime.of(2023, 05, 20, 20 , 20)) .beginEventDateTime(LocalDateTime.of(2023, 05, 20, 20, 20)) .endEventDateTime(LocalDateTime.of(2023, 11, 26, 20, 20)) .basePrice(10000) .maxPrice(200) .limitOfEnrollment(100) .location("4.강남역 D2 스타텀 팩토리") .eventStatus(EventStatus.PUBLISHED) .build(); this.mockMvc.perform(MockMvcRequestBuilders.post("/api/events") .contentType(MediaType.APPLICATION_JSON) .accept(MediaTypes.HAL_JSON) .content(objectMapper.writeValueAsString(event))) .andDo((print())) .andExpect(status().isBadRequest()) .andExpect(jsonPath("errors[0].objectName").exists()) .andExpect(jsonPath("errors[0].defaultMessage").exists()) .andExpect(jsonPath("errors[0].code").exists()) .andExpect(jsonPath("_links.index").exists()); // 추가 } }
에러처리시 메인화면으로 이동 처리를 위해
ErrosResource
@Component 로 구성해 줘야 한다.
package net.macaronics.restapi.common; import net.macaronics.restapi.index.IndexController; import org.springframework.hateoas.EntityModel; import org.springframework.stereotype.Component; import org.springframework.validation.Errors; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; @Component public class ErrorsResource{ public EntityModel addLink(Errors content) { EntityModel<Errors> entityModel = EntityModel.of(content); entityModel.add(linkTo(methodOn(IndexController.class).index()).withRel("index")); return entityModel; } }
EventController
private final ErrorsResource errorsResource; @PostMapping public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors) throws Exception { if (errors.hasErrors()) { // return ResponseEntity.badRequest().body(errors); return badRequest(errors); } //커스텀 validate 검사 eventValidator.validate(eventDto, errors); if(errors.hasErrors()){ // return ResponseEntity.badRequest().body(errors); return badRequest(errors); } ~ private ResponseEntity<EntityModel> badRequest(Errors errors) { return ResponseEntity.badRequest().body(errorsResource.addLink(errors)); }
package net.macaronics.restapi.events; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import net.macaronics.restapi.common.ErrorsResource; import org.springframework.hateoas.*; import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.validation.Errors; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import java.net.URI; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; @Controller @RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE) @RequiredArgsConstructor @Log4j2 public class EventController { private final EventRepository eventRepository; // private final ModelMapper modelMapper; private final EventValidator eventValidator; private final ErrorsResource errorsResource; /** * methodOn 사용 * @return */ // @PostMapping("/method") // public ResponseEntity createEventMethod(){ // WebMvcLinkBuilder webMvcLinkBuilder = linkTo(methodOn(EventController.class).createEvent(null)); // return ResponseEntity.created(webMvcLinkBuilder.toUri()).build(); // } @PostMapping public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors) throws Exception { if (errors.hasErrors()) { // return ResponseEntity.badRequest().body(errors); return badRequest(errors); } //커스텀 validate 검사 eventValidator.validate(eventDto, errors); if(errors.hasErrors()){ // return ResponseEntity.badRequest().body(errors); return badRequest(errors); } //modelMapper 오류 //Event event=modelMapper.map(eventDto, Event.class); Event event = eventDto.toEvent(); Integer eventId = event.getId(); //유료인지 무료인지 변경처리 event.update(); Event newEvent=this.eventRepository.save(event);//저장 /** * * ★ 링크 생성하기 * EntityModel.of(newEvent); Resource 객체를 가져와서 사용 * * **/ WebMvcLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(eventId); URI createdUri = selfLinkBuilder.toUri(); log.info("* createdUri {} " , createdUri); //출력 => * createdUri http://localhost/api/events EntityModel eventResource = EntityModel.of(newEvent); //셀프링크 추가 방법 eventResource.add(linkTo(EventController.class).slash(eventId).withSelfRel()); //1)링크추가방법 eventResource.add(linkTo(EventController.class).withRel("query-events")); //2)링크추가방법 eventResource.add(selfLinkBuilder.withRel("update-event")); eventResource.add(Link.of("/docs/index.html#resource-events-create").withRel("profile")); return ResponseEntity.created(createdUri).body(eventResource); } private ResponseEntity<EntityModel> badRequest(Errors errors) { return ResponseEntity.badRequest().body(errorsResource.addLink(errors)); } }
댓글 ( 4)
댓글 남기기