스프링부트

spring boot 필드값 조건별 validation 하기 - json subtype

알쓸개잡 2023. 7. 29.

앞선 포스팅에서는 custom annotation 을 만들어서 조건별 validation 을 적용하는 방법을 알아보았다.

이번 포스팅에서는 @JsonTypeInfo 와 @JsonSubTypes 어노테이션을 이용하여 조건별 validation 을 적용하는 방법을 기록한다.

 

@JsonTypeInfo 와 @JsonSubTypes 는 특정 필드의 값에 따라서 Serialization, Deserialization 을 수행하는 클래스를 별도로 정의할 수 있다는 점에서 조건별로 validation 을 처리하는데 이용할 수 있을듯 하여 샘플을 만들어 보았다.

 

 

코드 구현

야구선수 등록 API 가 있고 야구선수 등록 데이터에는 uid, 이름, 선수타입, 타율, 홈런수, 방어율, 다승수 데이터가 있다.

선수타입에는 타자, 투수가 정의되어 있고 선수타입이 타자인 경우에는 타율, 홈런수 정보가 반드시 필요하고 투수인 경우에는 방어율, 다승수 정보가 반드시 필요하다.

 

DTO 정의

package com.example.conditional.validation.example.jsonsubtype.dto;

import com.example.conditional.validation.example.PlayerType;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@JsonTypeInfo(
	use = JsonTypeInfo.Id.NAME,
	property = "type",
	visible = true
)
@JsonSubTypes(
	{
		@JsonSubTypes.Type(value = HitterDTO.class, name = "HITTER"),
		@JsonSubTypes.Type(value = PitcherDTO.class, name = "PITCHER")
	}
)
@NoArgsConstructor
@AllArgsConstructor
@Setter
@Getter
public abstract class PlayerDTO {
	@NotBlank
	private String uid;

	@NotBlank
	private String name;

	@NotNull
	private PlayerType type;
}

PlayerDTO 의 type 값에 따라서 직렬화, 역직렬화 DTO 를 아래와 같이 정의하였다.

type 필드의 값 DTO 클래스
HITTER HitterDTO.class
PITCHER PitcherDTO.class
package com.example.conditional.validation.example.jsonsubtype.dto;

import com.example.conditional.validation.example.PlayerType;
import jakarta.validation.constraints.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.math.BigDecimal;

@Getter
@Setter
@NoArgsConstructor
public class HitterDTO extends PlayerDTO{

	//타율 (타자)
	@NotNull
	@DecimalMin("0.000") @DecimalMax("1.000")
	private BigDecimal battingAverage;

	//홈런 (타자)
	@NotNull
	@Min(0)	@Max(100)
	private Integer homeruns;

	@Builder
	public HitterDTO(String uid, String name, PlayerType type, 
					 BigDecimal battingAverage, Integer homeruns) {
		super(uid, name, type);
		this.battingAverage = battingAverage;
		this.homeruns = homeruns;
	}
}

 

package com.example.conditional.validation.example.jsonsubtype.dto;

import com.example.conditional.validation.example.PlayerType;
import jakarta.validation.constraints.*;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;

import java.math.BigDecimal;

@Getter
@Setter
public class PitcherDTO extends PlayerDTO{

	//방어율 (투수)
	@NotNull
	@DecimalMin("0.000") @DecimalMax("10.000")
	private BigDecimal era;

	//다승 (투수)
	@NotNull
	@Min(0)	@Max(100)
	private Integer wins;

	@Builder
	public PitcherDTO(String uid, String name, PlayerType type, 
					  BigDecimal era, Integer wins) {
		super(uid, name, type);
		this.era = era;
		this.wins = wins;
	}
}

 

Controller 정의

package com.example.conditional.validation.example.jsonsubtype.controller;

import com.example.conditional.validation.example.jsonsubtype.dto.PlayerDTO;
import com.example.conditional.validation.example.jsonsubtype.service.PlayerService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping(path = "/jsonsubtype")
@RequiredArgsConstructor
public class PlayerController {
	private final PlayerService playerService;

	@PostMapping(path = "/player", consumes = MediaType.APPLICATION_JSON_VALUE)
	public ResponseEntity<PlayerDTO> createPlayer(
		@Validated
		@RequestBody PlayerDTO playerDTO) {
		PlayerDTO playerDTO1 = playerService.create(playerDTO);
		return ResponseEntity.ok(playerDTO1);
	}
}

 

Service 정의

package com.example.conditional.validation.example.jsonsubtype.service;


import com.example.conditional.validation.example.jsonsubtype.dto.PlayerDTO;
import org.springframework.stereotype.Service;

@Service
public class PlayerService {

	public PlayerDTO create(PlayerDTO playerDTO) {
		return playerDTO;
	}
}

 

ControllerAdvicer

package com.example.conditional.validation.example;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
@Slf4j
public class ControllerAdvice {

	@ExceptionHandler(MethodArgumentNotValidException.class)
	public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException e, HttpServletRequest request) {
		Map<String, String> errorDefaultMessages = new HashMap<>();
		Map<String, Object> errors = new HashMap<>();
		String objectName = e.getObjectName();
		e.getFieldErrors()
			.forEach(error -> errorDefaultMessages.put(error.getField(), error.getDefaultMessage()));

		log.error("failure check request validation, uri: {}, objectName: {}, {}", request.getRequestURI(), objectName, errors);
		return ResponseEntity.badRequest().body(errorDefaultMessages);
	}
}

 

 

테스트 코드

package com.example.conditional.validation.example.jsonsubtype.controller;

import com.example.conditional.validation.example.PlayerType;
import com.example.conditional.validation.example.jsonsubtype.dto.HitterDTO;
import com.example.conditional.validation.example.jsonsubtype.dto.PitcherDTO;
import com.example.conditional.validation.example.jsonsubtype.dto.PlayerDTO;
import com.example.conditional.validation.example.jsonsubtype.service.PlayerService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;

import java.math.BigDecimal;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.*;
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.status;

@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = PlayerController.class)
@Import(PlayerService.class)
class PlayerControllerTest {

	@Autowired
	private MockMvc mockMvc;

	@DisplayName("타자 등록 유효성 체크 성공 테스트")
	@Test
	void create_hitter_validation_success_test() throws Exception {
		PlayerDTO playerDTO = HitterDTO.builder()
			.uid(UUID.randomUUID().toString())
			.name("hitter1")
			.type(PlayerType.HITTER)
			.battingAverage(new BigDecimal("0.400"))
			.homeruns(50)
			.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/jsonsubtype/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isOk())
			.andDo(print());
	}

	@DisplayName("투수 등록 유효성 체크 성공 테스트")
	@Test
	void create_pitcher_validation_success_test() throws Exception {
		PlayerDTO playerDTO = PitcherDTO.builder()
			.uid(UUID.randomUUID().toString())
			.name("pitcher")
			.type(PlayerType.PITCHER)
			.era(new BigDecimal("0.235"))
			.wins(10)
			.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/jsonsubtype/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isOk())
			.andDo(print());
	}

	@DisplayName("타자 등록 유효성 체크 실패 테스트 - 홈런정보 누락")
	@Test
	void create_hitter_validation_fail_test() throws Exception {
		PlayerDTO playerDTO = HitterDTO.builder()
			.uid(UUID.randomUUID().toString())
			.name("hitter1")
			.type(PlayerType.HITTER)
			.battingAverage(new BigDecimal("0.400"))
			.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/jsonsubtype/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isBadRequest())
			.andDo(print());
	}

	@DisplayName("투수 등록 유효성 체크 실패 테스트 - 다승정보 누락")
	@Test
	void create_pitcher_validation_fail_test() throws Exception {
		PlayerDTO playerDTO = PitcherDTO.builder()
			.uid(UUID.randomUUID().toString())
			.name("pitcher1")
			.type(PlayerType.PITCHER)
			.era(new BigDecimal("0.235"))
			.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/jsonsubtype/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isBadRequest())
			.andDo(print());
	}

	@DisplayName("타자 등록 유효성 체크 실패 테스트 - 홈런 개수 limit")
	@Test
	void create_hitter_validation_limit_fail_test() throws Exception {
		PlayerDTO playerDTO = HitterDTO.builder()
			.uid(UUID.randomUUID().toString())
			.name("hitter1")
			.type(PlayerType.HITTER)
			.battingAverage(new BigDecimal("0.400"))
			.homeruns(1000)
			.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/jsonsubtype/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isBadRequest())
			.andDo(print());
	}

	@DisplayName("투수 등록 유효성 체크 실패 테스트 - 다승 개수 limit")
	@Test
	void create_pitcher_validation_limit_fail_test() throws Exception {
		PlayerDTO playerDTO = PitcherDTO.builder()
			.uid(UUID.randomUUID().toString())
			.name("pitcher1")
			.type(PlayerType.PITCHER)
			.era(new BigDecimal("0.235"))
			.wins(1000)
			.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/jsonsubtype/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isBadRequest())
			.andDo(print());
	}

	@DisplayName("타자 생성시 투수 정보도 입력했을 때 테스트")
	@Test
	void create_player_include_all_fields_test() throws Exception {
		com.example.conditional.validation.example.annotation.dto.PlayerDTO playerDTO =
			com.example.conditional.validation.example.annotation.dto.PlayerDTO
				.builder()
				.uid(UUID.randomUUID().toString())
				.name("hitter1")
				.type(PlayerType.HITTER)
				.battingAverage(new BigDecimal("0.300"))
				.homeruns(50)
				.era(new BigDecimal("0.000"))
				.wins(20)
				.build();

		ObjectMapper objectMapper = new ObjectMapper();

		mockMvc.perform(
				post("/jsonsubtype/player")
					.contentType(MediaType.APPLICATION_JSON_VALUE)
					.content(objectMapper.writeValueAsString(playerDTO))
			)
			.andExpect(status().isOk())
			.andDo(print());
	}
}

 

테스트 결과는 아래와 같다.

필드의 값 조건에 따라서 유효성 검사를 필드가 다른 경우 @JsonTypeInfo, @JsonSubTypes 를 통해서 처리하는 방법을 알아 보았다.

annotation 정의 방법에 비해서 DTO 클래스를 잘 구분지어서 정의하면 조금 더 손쉽게 처리할 수 있을듯 하다.

 

git 샘플코드

https://gitlab.com/blog4031530/spring-conditional-validation-example

댓글

💲 추천 글