스프링

 

 

중급자를 위해 준비한
[백엔드] 강의입니다.

다양한 스프링 기술을 사용하여 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 해결 방법 

 

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

 

 

  1. (토큰 없이) 이벤트 목록 조회

    1. create 안 보임

  2. access token 발급 받기 (A 사용자 로그인)

  3. (유효한 A 토큰 가지고) 이벤트 목록 조회

    1. create event 보임

  4. (유효한 A 토큰 가지고) 이벤트 만들기

  5. (토큰 없이) 이벤트 조회

    1. update 링크 안 보임

  6. (유효한 A 토큰 가지고) 이벤트 조회

    1. update 링크 보임

  7. access token 발급 받기 (B 사용자 로그인)

  8. (유효한 B 토큰 가지고) 이벤트 조회

    1. 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();
    }




}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

좋은 일이 있으면 임금의 덕이라 하여 임금을 칭송하고 잘못이 있으면 나의 책임이라 하는 태도를 지니고 있으면, 그 감화는 저절로 백성에게 미쳐서 충성된 마음이 생기는 것이다. 공자가 한 말. -예기

댓글 ( 6)

댓글 남기기

작성