똑같은 삽질은 2번 하지 말자

Spring으로 REST API No.3(입력값 제한, ModelMapper, @Valid, Errors, JsonSerializer ) 본문

Spring/Spring Boot

Spring으로 REST API No.3(입력값 제한, ModelMapper, @Valid, Errors, JsonSerializer )

곽빵 2020. 6. 29. 17:40

지정된 값 이외의 값이 들어왔을때, 지정된 값이 들어오지 않았을 때

@Test
public void createEvent_is_BadRequest() throws Exception {
	Event event = Event.builder()
			.id(100) // 들어오면 안되는 값
			.name("Spring")
			.description("REST API")
			.beginEnrollmentDateTime(LocalDateTime.of(2020,6,28,14,11))
			.closeEnrollmentDateTime(LocalDateTime.of(2020,6,29,14,11))
			.beginEventDateTime(LocalDateTime.of(2020,6,30,14,11))
			.endEventDateTime(LocalDateTime.of(2020,6,30,16,11))
			.basePrice(100)
			.maxPrice(200)
			.limitOfEnrollment(100)
			.location("부산역 어딘가")
			.free(true) // 들어오면 안되는 값
			.offline(false) // 들어오면 안되는 값
			.build();
	
	mockMvc.perform(post("/api/events")
			.contentType(MediaType.APPLICATION_JSON_UTF8)
			.accept(MediaTypes.HAL_JSON)
			.content(objectMapper.writeValueAsString(event)))          
            .andExpect(status().isBadRequest());
}

id , free, offlline 은 클라이언트가 지정할 수 있는 값이 아닌데 들어온다 할때는

Entity가 아니고 일단 dto로 받는다.

 

EventDto.class

import java.time.LocalDateTime;

import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class EventDto {
	
	@NotEmpty
	private String name;
	@NotEmpty
	private String description;
	@NotNull
	private LocalDateTime beginEnrollmentDateTime;
	@NotNull
	private LocalDateTime closeEnrollmentDateTime;
	@NotNull
	private LocalDateTime beginEventDateTime;
	@NotNull
	private LocalDateTime endEventDateTime;
	private String location; // (optional) 이게 없으면 온라인 모임
	private int basePrice;
	private int maxPrice;
	private int limitOfEnrollment;
}

들어온 dto는 Entity로 Mapping을 해줘야 영속성 컨테스트가 값을 DB에 넣을 수 있는데,

 

그때 사용할 수 있는 친구가

ModelMapper

<dependency>
	<groupId>org.modelmapper</groupId>
	<artifactId>modelmapper</artifactId>
	<version>2.3.1</version>
</dependency>
@Bean
public ModelMapper modelMapper() {
	return new ModelMapper();
}

빈으로 등록한 뒤, mapping뒤 저장해주면 된다.

 

Event.class

@Builder 
@AllArgsConstructor @NoArgsConstructor // public default 생성자
@Getter @Setter // @Data에는 EqualsAndHashCode가 기본적으로 정의되어있어서 겹침
@EqualsAndHashCode(of = "id") 
// 기본적으로 객체의 모든 필드로 Equals, HashCode를 비교하는데 그럼 
// 나중에 연관관계에서 (상호참조하는) StackOverFlow가 발생할 수 있으므로 ID로만 비교한다.
@Entity
public class Event {
	@Id @GeneratedValue
	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;
	private int maxPrice;
	private int limitOfEnrollment;
	private boolean offline;
	private boolean free;
	
	@Enumerated(EnumType.STRING)
	private EventStatus eventStatus;
	
//	@ManyToOne
//	private Account manager;
	
	public void update() {
		// Update Free
		if(this.basePrice == 0 && this.maxPrice == 0) {
			this.free = true;
		} else {
			this.free = false;
		}
		
		//Update offline
		if(this.location == null || this.location.trim().isEmpty())
			this.offline = false;
		else 
			this.offline = true;
	}
}

 

그리고 이렇게 Mapping

Event event = modelMapper.map(eventDto,Event.class);
eventRepository.save(event); // Entity로 넣을 수 있다.

 

 

 

그리고 받을때 EventDto객체로 받는데 모르는 속성(입력되면 안되는 속성)이 들어올 때,

따로 처리를 안해줘도 되는 설정을 안하고 BadRequest를 날릴리면 SpringBoot가 지원하는 jackson에 좋은 친구가 있다.

 

application.properties.xml

spring.jackson.deserialization.fail-on-unknown-properties=true

 

 

 

객체를 JSON으로 바꾸는데 매칭이 안되는 프로퍼티가 들어오면 BadRequest를 날려주는 설정

 

EventController

import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;

import java.net.URI;

import javax.validation.Valid;

import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.hateoas.MediaTypes;
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;

@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_UTF8_VALUE)
public class EventController {
	@Autowired
	EventRepository eventRepository;
	@Autowired
	ModelMapper modelMapper;
	@Autowired
	EventValidator eventValidator;
	
	@PostMapping
	public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors) {
		if(errors.hasErrors())
			return ResponseEntity.badRequest().build();
		eventValidator.validate(eventDto, errors);
		if(errors.hasErrors())
			return ResponseEntity.badRequest().build();
		Event event = modelMapper.map(eventDto,Event.class);
		Event newEvent = this.eventRepository.save(event);
		URI createUri = 
				linkTo(EventController.class).slash(newEvent.getId()).toUri();
		return ResponseEntity.created(createUri).body(event);
		 // event는 자바빈 스펙을 준수하므로 BeanSerializer로 json으로 변환해서 자동으로 넘어간다.
 	}
}

지정된 값이 들어오지 않았을 때는 @Valid @NotEmpty로 체크해준다.

이런것을 Dto가 대신해서 해주는중

 

잘못된 값이 들어왔을때(비즈니스 로직상 맞지않는 값)

 

따로 Validator 를 만들어서 그때 그때 맞춰서 사용해 준다.

import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;

@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");
		}
		if(eventDto.getBeginEventDateTime().isAfter(eventDto.getEndEventDateTime())){
			errors.rejectValue("endEventTime", "wrongValue","EndEventTime is Wrong");
		}
	}
}

Dto와 errors 객체를 받아서 검사를 하고 잘못된 값이 있으면 errors에 내용을 담아 넘겨준다.

 

 

Errors 의 내용을 ResponseBody에 넣어서 JSON의 형태로 넘겨줄려면 Serialize가 필요하다.

그럼 Event 도메인을 넘길때는 왜 그냥 넣기만해도 됬냐? 라고 한다면, Java Bean 준수하기 때문에

BeanSerializer가 Serialize해서 넘겨주므로 따로 필요가 없다.

그러므로 따로 ErrorSerializer를 만들어서 컴포넌트 등록도 해줘야 한다.

ErrorSerializer(JsonSerializer)

import java.io.IOException;

import org.springframework.boot.jackson.JsonComponent;
import org.springframework.validation.Errors;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;

@JsonComponent
public class ErrorSerializer extends JsonSerializer<Errors>{
	@Override
	public void serialize(Errors errors, JsonGenerator gen, SerializerProvider serializers) throws IOException {
		gen.writeStartArray();
		errors.getFieldErrors().stream().forEach(e->{
			try {
				gen.writeStartObject();
				gen.writeStringField("field", e.getField());
				gen.writeStringField("objectName",e.getObjectName());
				gen.writeStringField("code", e.getCode());
				gen.writeStringField("defaultMessage", e.getDefaultMessage());
				Object rejectedValue = e.getRejectedValue();
				if(rejectedValue != null)
					gen.writeStringField("rejectedValue", (String)rejectedValue);
				gen.writeEndObject();
			}catch(Exception er) {
				System.out.println(er);
			}
		});
		
		errors.getGlobalErrors().stream().forEach(e->{
			try {
				gen.writeStartObject();
				gen.writeStringField("objectName",e.getObjectName());
				gen.writeStringField("code", e.getCode());
				gen.writeStringField("defaultMessage", e.getDefaultMessage());
				gen.writeEndObject();
			}catch(Exception er) {
				System.out.println(er);
			}
		});
		gen.writeEndArray();
	}
}

이렇게 JsonComponent로 등록하면 ObjectMapper가 Errors라는 객체를 Serialize할때 이 ErrorsSerializer를 사용한다.

 

 

이제 return 할때 응답본문에 그냥 객체 통짜로 넣어도 제대로 반환이 된다.

Comments