중급자를 위해 준비한
[백엔드] 강의입니다.
다양한 스프링 기술을 사용하여 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. 강좌 소개
첫 페이지 참고
소스 코드
2. 강사 소개
백기선
현재 마이크로소프트 미국 본사에 근무 중. (그전에는 네이버와 아마존에서 일을 했습니다.)
2007년부터 개발자로 일했으며 이제 막 경력 10년이 조금 넘었네요.
자바, 스프링 프레임워크, JPA, 하이버네이트를 주로 공부하고 공유해 왔습니다.
Youtube/백기선 채널에서 코딩 관련 정보를 영상으로 공유하고 있습니다.
(예전에는 Whiteship.me 라는 블로그에 글도 많이 올렸지만 요즘은 잘 안써요.)
(더 예전에는 책도 쓰고 번역도 하고 발표도 많이 했었지만 역시나.. 요즘은 안합니다.)
[1. REST API 및 프로젝트 소개]
3. REST API
강의 : https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16409&tab=curriculum
API
Application Programming Interface
REST
REpresentational State Transfer
인터넷 상의 시스템 간의 상호 운용성(interoperability)을 제공하는 방법중 하나
시스템 제각각의 독립적인 진화를 보장하기 위한 방법
REST API: REST 아키텍처 스타일을 따르는 API
REST 아키텍처 스타일 (발표 영상 11분)
Client-Server
Stateless
Cache
Uniform Interface
Layered System
Code-On-Demand (optional)
Uniform Interface (발표 영상 11분 40초)
Identification of resources
manipulation of resources through represenations
self-descrive messages
hypermedia as the engine of appliaction state (HATEOAS)
두 문제를 좀 더 자세히 살펴보자. (발표 영상 37분 50초)
Self-descriptive message
메시지 스스로 메시지에 대한 설명이 가능해야 한다.
서버가 변해서 메시지가 변해도 클라이언트는 그 메시지를 보고 해석이 가능하다.
확장 가능한 커뮤니케이션
HATEOAS
하이퍼미디어(링크)를 통해 애플리케이션 상태 변화가 가능해야 한다.
링크 정보를 동적으로 바꿀 수 있다. (Versioning 할 필요 없이!)
Self-descriptive message 해결 방법
방법 1: 미디어 타입을 정의하고 IANA에 등록하고 그 미디어 타입을 리소스 리턴할 때 Content-Type으로 사용한다.
방법 2: profile 링크 헤더를 추가한다. (발표 영상 41분 50초)
대안으로 HAL의 링크 데이터에 profile 링크 추가
HATEOAS 해결 방법
방법1: 데이터에 링크 제공
링크를 어떻게 정의할 것인가? HAL
방법2: 링크 헤더나 Location을 제공
4. Event REST API
강의 : https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16410&tab=curriculum
이벤트 등록, 조회 및 수정 API
GET /api/events
이벤트 목록 조회 REST API (로그인 안 한 상태)
응답에 보여줘야 할 데이터
이벤트 목록
링크
self
profile: 이벤트 목록 조회 API 문서로 링크
get-an-event: 이벤트 하나 조회하는 API 링크
next: 다음 페이지 (optional)
prev: 이전 페이지 (optional)
문서?
스프링 REST Docs로 만들 예정
이벤트 목록 조회 REST API (로그인 한 상태)
응답에 보여줘야 할 데이터
이벤트 목록
링크
self
profile: 이벤트 목록 조회 API 문서로 링크
get-an-event: 이벤트 하나 조회하는 API 링크
create-new-event: 이벤트를 생성할 수 있는 API 링크
next: 다음 페이지 (optional)
prev: 이전 페이지 (optional)
로그인 한 상태???? (stateless라며..)
아니, 사실은 Bearer 헤더에 유효한 AccessToken이 들어있는 경우!
POST /api/events
이벤트 생성
GET /api/events/{id}
이벤트 하나 조회
PUT /api/events/{id}
이벤트 수정
5. Events API 사용 예제
강의 : https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16410&tab=curriculum
https://restlet.talend.com/downloads/current/
6. Postman & Restlet
강의 : https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16411&tab=curriculum
(토큰 없이) 이벤트 목록 조회
create 안 보임
access token 발급 받기 (A 사용자 로그인)
(유효한 A 토큰 가지고) 이벤트 목록 조회
create event 보임
(유효한 A 토큰 가지고) 이벤트 만들기
(토큰 없이) 이벤트 조회
update 링크 안 보임
(유효한 A 토큰 가지고) 이벤트 조회
update 링크 보임
access token 발급 받기 (B 사용자 로그인)
(유효한 B 토큰 가지고) 이벤트 조회
update 안 보임
REST API 테스트 클라이언트 애플리케이션
크롬 플러그인
Restlet
애플리케이션
Postman
7. Project 만들기
강의 : https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16412&tab=curriculum
추가할 의존성
Web
JPA
HATEOAS
REST Docs
H2
PostgreSQL
Lombok
자바 버전 11로 시작
스프링 부트 핵심 원리
의존성 설정 (pom.xml)
자동 설정 (@EnableAutoConfiguration)
내장 웹 서버 (의존성과 자동 설정의 일부)
독립적으로 실행 가능한 JAR (pom.xml의 플러그인)
pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.0.5</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>net.macaronics</groupId> <artifactId>restapi</artifactId> <version>0.0.1-SNAPSHOT</version> <name>restapi</name> <description>spring boot rest-api</description> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-hateoas</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.restdocs</groupId> <artifactId>spring-restdocs-mockmvc</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <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>${spring-restdocs.version}</version> </dependency> </dependencies> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.2</version> <configuration> <skipTests>true</skipTests> </configuration> </plugin> </plugins> </build> </project>
스프링부트 3.0 이상 패키지 생성 오류시 다음을 추가 할것
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.2</version> <configuration> <skipTests>true</skipTests> </configuration> </plugin>
8. 이벤트 도메인 구현
강의 : https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16413&tab=curriculum
public class Event { private String name; private String description; private LocalDateTime beginEnrollmentDateTime; private LocalDateTime closeEnrollmentDateTime; private LocalDateTime beginEventDateTime; private LocalDateTime endEventDateTime; private String location; // (optional) 이게 없으면 온라인 모임 private int basePrice; // (optional) private int maxPrice; // (optional) private int limitOfEnrollment; }
추가 필드
private Integer id; private boolean offline; private boolean free; private EventStatus eventStatus = EventStatus.DRAFT;
EventStatus 이늄 추가
public enum EventStatus { DRAFT, PUBLISHED, BEGAN_ENROLLMEND, CLOSED_ENROLLMENT, STARTED, ENDED }
롬복 애노테이션 추가
@Getter @Setter @EqualsAndHashCode(of = "id") @Builder @NoArgsConstructor @AllArgsConstructor public class Event {
왜 @EqualsAndHasCode에서 of를 사용하는가
왜 @Builder를 사용할 때 @AllArgsConstructor가 필요한가
@Data를 쓰지 않는 이유
애노테이션 줄일 수 없나
9. 이벤트 비즈니스 로직
강의 : https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16414&tab=curriculum
Event 생성 API
다음의 입력 값을 받는다.
name
description
beginEnrollmentDateTime
closeEnrollmentDateTime
beginEventDateTime
endEventDateTime
location (optional) 이게 없으면 온라인 모임
basePrice (optional)
maxPrice (optional)
limitOfEnrollment
basePrice와 maxPrice 경우의 수와 각각의 로직
결과값
id
name
...
eventStatus: DRAFT, PUBLISHED, ENROLLMENT_STARTED, ...
offline
free
_links
profile (for the self-descriptive message)
self
publish
...
[2. 이벤트 생성 API 개발]
10. 이벤트 API 테스트 클래스 생성
강의 : https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16416&tab=curriculum
package net.macaronics.restapi.events; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.hateoas.MediaTypes; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; //@SpringBootTest @WebMvcTest public class EventControllerTests { @Autowired MockMvc mockMvc; @Test public void createEvent() throws Exception{ mockMvc.perform( post("/api/events/") .contentType(MediaType.APPLICATION_JSON_UTF8).accept(MediaTypes.HAL_JSON) ).andExpect(status().isCreated()); } }
11. 201 응답 받기
강의 : https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16417
EventController
package net.macaronics.restapi.events; 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.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; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; @Controller @RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE) public class EventController { /** * 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 Event event){ URI createdUri = linkTo(EventController.class).slash("{id}").toUri(); event.setId(11); return ResponseEntity.created(createdUri).body(event); } }
EventControllerTests
package net.macaronics.restapi.events; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.hateoas.MediaTypes; 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.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest public class EventControllerTests { @Autowired MockMvc mockMvc; @Autowired ObjectMapper objectMapper; @Test public void createEvent() 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(100) .maxPrice(200) .limitOfEnrollment(100) .location("강남역 D2 스타텀 팩토리") .eventStatus(EventStatus.DRAFT) .build(); //MediaType.APPLICATION_JSON_UTF8 --> 버전 문제 다음 사용할것 //MediaTypes.HAL_JSON_VALUE 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()); } }
12. 이벤트 Repository
강의 : https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16418&tab=curriculum
스프링 데이터 JPA
JpaRepository 상속 받아 만들기
Enum을 JPA 맵핑시 주의할 것
@Enumerated(EnumType.STRING)
@MockBean
Mockito를 사용해서 mock 객체를 만들고 빈으로 등록해 줌.
(주의) 기존 빈을 테스트용 빈이 대체 한다.
테스트 할 것
입력값들을 전달하면 JSON 응답으로 201이 나오는지 확인.
Location 헤더에 생성된 이벤트를 조회할 수 있는 URI 담겨 있는지 확인.
id는 DB에 들어갈 때 자동생성된 값으로 나오는지 확인
EventController
package net.macaronics.restapi.events; import lombok.RequiredArgsConstructor; import org.springframework.hateoas.MediaTypes; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; 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 public class EventController { private final EventRepository eventRepository; /** * 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 Event event){ Event newEvent=this.eventRepository.save(event); URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri(); return ResponseEntity.created(createdUri).body(event); } }
EventControllerTests
package net.macaronics.restapi.events; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; 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.*; @WebMvcTest public class EventControllerTests { @Autowired MockMvc mockMvc; @Autowired ObjectMapper objectMapper; @MockBean EventRepository eventRepository; @Test public void createEvent() 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(100) .maxPrice(200) .limitOfEnrollment(100) .location("강남역 D2 스타텀 팩토리") .eventStatus(EventStatus.DRAFT) .build(); event.setId(10); Mockito.when(eventRepository.save(event)).thenReturn(event); //MediaType.APPLICATION_JSON_UTF8 --> 버전 문제 다음 사용할것 //MediaTypes.HAL_JSON_VALUE // Headers = [Location:"http://localhost/api/events/10", Content-Type:"application/hal+json"] 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)); } }
13. 입력값 제한하기
강의 :
https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16419&tab=curriculum
입력값 제한
id 또는 입력 받은 데이터로 계산해야 하는 값들은 입력을 받지 않아야 한다.
EventDto 적용
DTO -> 도메인 객체로 값 복사
ModelMapper
<dependency> <groupId>org.modelmapper</groupId> <artifactId>modelmapper</artifactId> <version>3.1.1</version> </dependency>
통합 테스트로 전환
@WebMvcTest 빼고 다음 애노테이션 추가
@SpringBootTest
@AutoConfigureMockMvc
Repository @MockBean 코드 제거
입력값으로 누가 id나 eventStatus, offline, free 이런 데이터까지 같이 주면?
Bad_Request로 응답 vs 받기로 한 값 이외는 무시
==>
package net.macaronics.restapi; import net.macaronics.restapi.events.Event; import net.macaronics.restapi.events.EventDto; import org.modelmapper.ModelMapper; import org.modelmapper.TypeMap; import org.modelmapper.config.Configuration; import org.modelmapper.convention.MatchingStrategies; import org.modelmapper.convention.NameTokenizers; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import javax.print.attribute.standard.Destination; @SpringBootApplication public class RestapiApplication { public static void main(String[] args) { SpringApplication.run(RestapiApplication.class, args); } @Bean public ModelMapper modelMapper() { ModelMapper modelMapper=new ModelMapper(); //setter 아닌 필드로 주입 modelMapper.getConfiguration() .setFieldAccessLevel(Configuration.AccessLevel.PRIVATE) .setFieldMatchingEnabled(true) .setSkipNullEnabled(true); return modelMapper; } }
Event
import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; @Builder @AllArgsConstructor @NoArgsConstructor @Getter //@Setter @EqualsAndHashCode(of="id") @Entity public class Event { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String name; private String description; private LocalDateTime beginEnrollmentDateTime; private LocalDateTime closeEnrollmentDateTime; private LocalDateTime beginEventDateTime; private LocalDateTime endEventDateTime; private String location; // (optional) 이게 없으면 온라인 모임 private int basePrice; // (optional) private int maxPrice; // (optional)- private int limitOfEnrollment; private boolean offline; private boolean free; @Enumerated(EnumType.STRING) private EventStatus eventStatus; }
EventDto
import lombok.*; import java.time.LocalDateTime; @Builder @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(of="id") @Getter @ToString public class EventDto { private String name; private String description; private LocalDateTime beginEnrollmentDateTime; private LocalDateTime closeEnrollmentDateTime; private LocalDateTime beginEventDateTime; private LocalDateTime endEventDateTime; private String location; // (optional) 이게 없으면 온라인 모임 private int basePrice; // (optional) private int maxPrice; // (optional)- private int limitOfEnrollment; public Event toEvent(){ return Event.builder() .name(this.name) .description(this.description) .beginEnrollmentDateTime(this.beginEnrollmentDateTime) .closeEnrollmentDateTime(this.closeEnrollmentDateTime) .beginEventDateTime(this.beginEventDateTime) .endEventDateTime(this.endEventDateTime) .location(this.location) .basePrice(this.basePrice) .maxPrice(this.maxPrice) .limitOfEnrollment(this.limitOfEnrollment) .build(); } }
EventController
~ @Controller @RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE) public class EventController { private final EventRepository eventRepository; @Autowired private ModelMapper modelMapper; public EventController(EventRepository eventRepository){ this.eventRepository=eventRepository; } ~ @PostMapping public ResponseEntity createEvent(@RequestBody EventDto eventDto){ Event event=modelMapper.map(eventDto, Event.class); //Event event = eventDto.toEvent(); try{ modelMapper.validate(); }catch (ValidationException e){ e.printStackTrace(); System.out.println(" ValidationException : " + e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); } Event newEvent=this.eventRepository.save(event); System.out.println(" newEvent 저장후 " +newEvent.getId()); URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri(); return ResponseEntity.created(createdUri).body(event); }
EventControllerTests
import com.fasterxml.jackson.databind.ObjectMapper; import org.hamcrest.Matchers; 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.*; //@WebMvcTest @SpringBootTest @AutoConfigureMockMvc public class EventControllerTests { @Autowired MockMvc mockMvc; @Autowired ObjectMapper objectMapper; /** * Mock은 껍데기만 있는 객체를 얘기합니다. * 인터페이스의 추상메소드가 메소드 바디는 없고 파라미터 타입과 리턴타입만 선언된 것처럼, Mock Bean은 * 기존에 사용되던 Bean의 껍데기만 가져오고 내부의 구현 부분은 모두 사용자에게 위임한 형태입니다. */ // @MockBean // EventRepository eventRepository; @Test 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(true) .offline(false) .build(); //Mockito.when(eventRepository.save(event)).thenReturn(event); //MediaType.APPLICATION_JSON_UTF8 --> 버전 문제 다음 사용할것 //MediaTypes.HAL_JSON_VALUE // Headers = [Location:"http://localhost/api/events/10", Content-Type:"application/hal+json"] System.out.println("event: "+objectMapper.writeValueAsString(event)); 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(jsonPath("id").value(Matchers.not(100))) .andExpect(jsonPath("free").value(Matchers.not(true)) ); } }
위와같이 설정은 좋았다.
강좌에서는 특별히 설정을 안해도 정상작동했으나,
그러나 다음과 같은 이 Unmapped destination properties found in TypeMap 에 대한 해당 필드가 없어서 매핑이 안되어서 오류가났다.
setFieldMatchingEnabled(true) 해 주었음에도 불구하고 오류 발생
버전을 변경하고 기타 여러방법을 시도했는데.
properties found in TypeMap 처리를 해결할 수없었다.
ModelMapper validation errors: 1) Unmapped destination properties found in TypeMap[EventDto -> Event ]: net.macaronics.restapi.events.Event.id net.macaronics.restapi.events.Event.offline net.macaronics.restapi.events.Event.free net.macaronics.restapi.events.Event.eventStatus 1 error
결론 => 다음과 같이 직접 builder 매핑 처리를 해주었다.
public Event toEvent(){ return Event.builder() .name(this.name) .description(this.description) .beginEnrollmentDateTime(this.beginEnrollmentDateTime) .closeEnrollmentDateTime(this.closeEnrollmentDateTime) .beginEventDateTime(this.beginEventDateTime) .endEventDateTime(this.endEventDateTime) .location(this.location) .basePrice(this.basePrice) .maxPrice(this.maxPrice) .limitOfEnrollment(this.limitOfEnrollment) .build(); }
14. 입력값 이외에 에러 발생
강의 :
https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16420&tab=curriculum
ObjectMapper 커스터마이징
spring.jackson.deserialization.fail-on-unknown-properties=true
테스트 할 것
입력값으로 누가 id나 eventStatus, offline, free 이런 데이터까지 같이 주면? Bad_Request로 응답 vs 받기로 한 값 이외는 무시
서버구동시에는 정상작동하나, 다음과 같이 테스코드 작성후 테스트시에 오류 발생
@Test public void createEvent_Bad_Request() 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(true) .offline(false) .eventStatus(EventStatus.PUBLISHED) .build(); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) .accept(MediaTypes.HAL_JSON) .content(objectMapper.writeValueAsString(event))) .andDo((print())) .andExpect(status().isBadRequest()); }
15. Bad Request 처리
강의 :
https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16421&tab=curriculum
@Valid와 BindingResult (또는 Errors)
BindingResult는 항상 @Valid 바로 다음 인자로 사용해야 함. (스프링 MVC)
@NotNull, @NotEmpty, @Min, @Max, ... 사용해서 입력값 바인딩할 때 에러 확인할 수 있음
도메인 Validator 만들기
Validator 인터페이스 없이 만들어도 상관없음
테스트 설명 용 애노테이션 만들기
@Target, @Retention
테스트 할 것
입력 데이터가 이상한 경우 Bad_Request로 응답
입력값이 이상한 경우 에러
비즈니스 로직으로 검사할 수 있는 에러
에러 응답 메시지에 에러에 대한 정보가 있어야 한다.
EventValidator
import org.springframework.stereotype.Component; import org.springframework.validation.Errors; import java.time.LocalDateTime; @Component public class EventValidator { public void validate(EventDto eventDto, Errors errors){ if(eventDto.getBasePrice() > eventDto.getMaxPrice() && eventDto.getMaxPrice() !=0){ errors.rejectValue("basePrice","wrongValue", "BasePrice is wrong." ); errors.rejectValue("maxPrice", "wrongValue", "MaxPrice is wrong."); } LocalDateTime endEventDateTime = eventDto.getEndEventDateTime(); if(endEventDateTime.isBefore(eventDto.getBeginEventDateTime()) || endEventDateTime.isBefore(eventDto.getCloseEnrollmentDateTime()) || endEventDateTime.isBefore(eventDto.getBeginEnrollmentDateTime() ) ){ errors.rejectValue("endEventDateTime", "wrongValue", "EndEventDateTime is wrong"); } } }
EventController
@PostMapping public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors){ if(errors.hasErrors()){ System.out.println("첫번째 Bad Request 처리"); return ResponseEntity.badRequest().build(); } eventValidator.validate(eventDto, errors); if(errors.hasErrors()){ System.out.println("두번째 Bad Request 처리"); return ResponseEntity.badRequest().build(); } //Event event=modelMapper.map(eventDto, Event.class); Event event = eventDto.toEvent(); Event newEvent=this.eventRepository.save(event); URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri(); return ResponseEntity.created(createdUri).body(event); }
TEST
@Test @DisplayName("입력 받을 수 없는 값을 사용한 경우에 에러가 발생하는 테스트") //spring.jackson.deserialization.fail-on-properties=true public void createEvent_Bad_Request() 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(true) .offline(false) .eventStatus(EventStatus.PUBLISHED) .build(); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) .accept(MediaTypes.HAL_JSON) .content(objectMapper.writeValueAsString(event))) .andDo((print())) .andExpect(status().isBadRequest()); }
Bad Request 응답
비즈니스 로직 적용
매개변수를 이용한 테스트
16. Bad Request 응답
강의 :
https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16422&tab=curriculum
커스텀 JSON Serializer 만들기
extends JsonSerializer<T> (Jackson JSON 제공)
@JsonComponent (스프링 부트 제공)
BindingError
FieldError 와 GlobalError (ObjectError)가 있음
objectName
defaultMessage
code
field
rejectedValue
테스트 할 것
입력 데이터가 이상한 경우 Bad_Request로 응답 입력값이 이상한 경우 에러 비즈니스 로직으로 검사할 수 있는 에러 에러 응답 메시지에 에러에 대한 정보가 있어야 한다.
https://pupupee9.tistory.com/68
결론은 응답처리는 다음과 같이 Errors 를 Serializer 로 만들어 처리하면된다.
import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; import org.springframework.boot.jackson.JsonComponent; import org.springframework.validation.Errors; import java.io.IOException; //ObjectMapper 에 Custom Serializer를 등록해 주어야하는데 // Spring Boot에서 제공하는 @JsonComponent를 사용하면 손쉽게 등록이 가능하다. @JsonComponent public class ErrorsSerializer extends JsonSerializer<Errors> { @Override public void serialize(Errors errors, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeStartArray(); errors.getFieldErrors().forEach(e->{ try{ gen.writeStartObject(); gen.writeStringField("objectName",e.getObjectName()); gen.writeStringField("field",e.getField()); gen.writeStringField("defaultMessage",e.getDefaultMessage()); gen.writeStringField("code", e.getCode()); Object rejectedValue =e.getRejectedValue(); if(rejectedValue!=null){ gen.writeStringField("rejectedValue", rejectedValue.toString()); } gen.writeEndObject(); }catch (IOException e1){ e1.printStackTrace(); } }); errors.getGlobalErrors().stream().forEach(e->{ try{ gen.writeStartObject(); gen.writeStringField("objectName",e.getObjectName()); gen.writeStringField("defaultMessage",e.getDefaultMessage()); gen.writeStringField("code", e.getCode()); gen.writeEndObject(); }catch (IOException e1){ e1.printStackTrace(); } }); gen.writeEndArray(); } }
댓글 ( 6)
댓글 남기기