티스토리 뷰

728x90

0. 이 포스트는 이전 Fooball club예제를 REST로 다시 작성하는 시리즈의 일부이다.

 

1. 적용된 테크닉

  1-1 전역에러 처리 클래스 설정 @ControllerAdvice

  1-2 Rest Controller @Validated, @Valid 처리

  1-3 Optional 처리하기 map - orElseThrow/orElseGet 처리

 

2. 이 포스트에서 작성할 내용

  2-1 인증관련 연결

  2-2 전역 예외처리 클래스 정의

  2-3 Skill CRUD 구현하기

 

3. 인증 관련 연결하기

  3-1 SecurityConfig 

    3-1-1 기본적인 인증을 UserDetailsService를 바로 이용하는 방식으로 정의

    3-1-2 PasswordEncoder를 BCrypt방식으로 적용

    3-1-3 csrf disable하기 - 현재 모든 경로를 허용하기로 하더라도 post로 전송시 403 권한없음이 발생한다.

      3-1-3-0  disable처리를 하면 정상적으로 메시지 전송이 가능해 진다.

      3-1-3-1 서버에서 csrf를 기다리기 때문인데, 강제로 아래처럼 disable를 해주거나 아니면 post에 csrf를 보내야한다.

 

package pe.pilseong.footballserver.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

  @Autowired
  private UserDetailsService userDetailsService;

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests().anyRequest().permitAll()
    .and()
    .csrf().disable();
  }

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}

 

  3-2 UserDetailsService 구현하기

    3-2-1 위의 보안 설정에서 UserDetailsService를 사용하고 있기 때문에 Service구현하여 제공해야 한다.

    3-2-2 코드는 필수 메소드 loadUserByUsername을 구현하고 있고 UserRepository를 사용하고 있다.

    3-2-3 유저를 찾을 수 없는 경우, UserDetailsService의 스펙에 맞게 예외를 발생시키고 있다.

 

package pe.pilseong.footballserver.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import pe.pilseong.footballserver.repository.UserRepository;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

  @Autowired
  private UserRepository userRepository;

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    
    UserDetails userDetails = this.userRepository.findByUsername(username);

    if (userDetails != null) {
      return userDetails;
    }

    throw new UsernameNotFoundException("User '" + username + "' not found");
  }
}

 

  3-3 UserRepository 작성하기

    3-3-1 UserDetailsService에서 username으로 검색 요구하기 때문에 메소드 정의를 추가했다. 구현은 JPA가 해준다.

 

package pe.pilseong.footballserver.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import pe.pilseong.footballserver.model.User;

public interface UserRepository extends JpaRepository<User, Long> {

	User findByUsername(String username);
  
}

 

4. Entity Repository 작성하기

  4-1 다 동일한 방식이어서 하나 코드로 붙여놓았다. 모두 별도의 클래스를 만들어야 한다.

 

package pe.pilseong.footballserver.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import pe.pilseong.footballserver.model.Skill;

public interface SkillRepository extends JpaRepository<Skill, Long> {
  
}





package pe.pilseong.footballserver.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import pe.pilseong.footballserver.model.Player;

public interface PlayerRepository extends JpaRepository<Player, Long> {
  
}





package pe.pilseong.footballserver.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import pe.pilseong.footballserver.model.Team;

public interface TeamRepository extends JpaRepository<Team, Long> {
  
}




package pe.pilseong.footballserver.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import pe.pilseong.footballserver.model.Role;

public interface RoleRepository extends JpaRepository<Role, Long> {
  
}

 

5. Skill REST CRUD 작성하기

  5-0 Optional을 사용한 깔끔한 코딩방식을 선택하였고, 모든 예외사항은 전역 예외핸들러에서 처리하였다.

  5-1 (GET) findOne메소드@Min 검증자를 사용하여 최소 숫자값 1이상의 값을 입력해야 한다.

    5-1-1 @Min을 사용하려면 @Validated라는 annotation을 클래스 레벨에 붙여야 한다.

      5-1-1-1 검증을 통과하지 못하면 ConstraintViolationException 이 발생한다.

      5-1-1-2 데이터베이스에 없는 id가 들어오면 SkillNotFoundException을 발생시킨다. 사용자 정의 예외이다.

  5-2 (POST) saveSkill 메소드는 @Valid를 통해 Skill로 변환되는 객체를 검증하고 있다.

    5-2-1 Type의 enum에 없는 값이 들어 올 경우 HttpMessageNotReadableException이 발생한다.

    5-2-2 필수항목이 빠져 있는 경우 MethodArgumentNotValidException이 발생한다.

  5-3 (PATCH) updateSkill 메소드에는 Map으로 키:값 쌍을 받아 있는 정보만 업데이트한다

    5-3-1 업데이트할 Type enum이 없는 값인 경우 illegalArgumentExceptionHandler이 발생한다.

  5-4 (PUT) saveOrUpdate는 saveSkill와 비슷하게 예외가 발생한다.

    5-4-1 필수항목이 빠진 patch는 MethodArgumentNotValidException 발생

    5-4-2 Type의 없는 값이 들어오면 HttpMessageNotReadableException 발생

 

package pe.pilseong.footballserver.controller;

import java.util.List;
import java.util.Map;

import javax.validation.Valid;
import javax.validation.constraints.Min;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import lombok.extern.slf4j.Slf4j;
import pe.pilseong.footballserver.exception.SkillNotFoundException;
import pe.pilseong.footballserver.model.Skill;
import pe.pilseong.footballserver.model.Skill.Type;
import pe.pilseong.footballserver.repository.SkillRepository;

@RestController
@RequestMapping("/api/skills")
@Validated
@Slf4j
public class SkillController {

  @Autowired
  private SkillRepository skillRepository;

  @GetMapping
  @ResponseStatus(code = HttpStatus.OK)
  public List<Skill> findAll() {
    return this.skillRepository.findAll();
  }

  @GetMapping("/{id}")
  public Skill findOne(@PathVariable @Min(1) Long id) {

    return this.skillRepository.findById(id)
      .orElseThrow(()-> new SkillNotFoundException(id));
  }

  @PostMapping
  public Skill saveSkill(@Valid @RequestBody Skill skill) {

    log.info(skill.toString());

    return this.skillRepository.save(skill);
  }

  @DeleteMapping("/{id}")
  @ResponseStatus(code = HttpStatus.NO_CONTENT)
  public void deleteSkillById(@PathVariable @Min(1) Long id) {
    this.skillRepository.deleteById(id);
  }

  @PutMapping("/{id}")
  public Skill saveOrUpdate(@Valid @RequestBody Skill newSkill, @PathVariable Long id) {

    return this.skillRepository.findById(id)
      .map(skill-> {
        skill.setName(newSkill.getName());
        skill.setType(newSkill.getType());
        return this.skillRepository.save(skill);
      })
      .orElseGet(()-> {
        newSkill.setId(id);
        return this.skillRepository.save(newSkill);
      });
  }

  @PatchMapping("/{id}")
  public Skill updateSkill(@RequestBody Map<String, String> update, @PathVariable Long id) {

    return this.skillRepository.findById(id)
      .map(skill-> {
        if (update.containsKey("name")) {
          skill.setName(update.get("name"));
        }
        if (update.containsKey("type")) {
          skill.setType(Type.valueOf(update.get("type")));
        }
        return this.skillRepository.save(skill);
      })
      .orElseGet(() -> {
        throw new SkillNotFoundException(id);
      });
  }
}

 

  5-5 검증을 위해 Skill 클래스를 수정

    5-5-1 name, type 둘 다 필수로 지정하였다.

    5-5-2 type의 enum이기 때문에 NotEmpty나 NotBlank가 지정되지 않는다. NotNull로 충분하다.

 

package pe.pilseong.footballserver.model;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.Table;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;

import lombok.Data;
import lombok.EqualsAndHashCode;

@Entity
@Table(name = "skills")
@Data
@EqualsAndHashCode(callSuper=false)
public class Skill extends AbstractEntity {
  
  @Column
  @NotEmpty(message = "name is requried")
  private String name;

  @Column
  @Enumerated(EnumType.STRING)
  @NotNull(message = "type is required")
  private Type type;

  public static enum Type {
    SHOOTING, PASSING, DEFENSING, PHYSICAL;
  }
}

 

6. 위의 다양한 예외를 처리하기 위한 전역 예외처리

  6-1 SkillController에서 발생한 다양한 예외를 여기에서 처리한다.

  6-2 ResponseEntityExceptionHandler는 기본적인 예외를 전부 처리하고 있다.

    6-2-1 필요한 경우 특정예외를 Override할 수 있다.

    6-2-2 없는 경우는 임의 메소드로 처리해야 한다.

    6-2-3 처리가 동일한 경우는 내부 메소드로 옮겼다.

    6-2-4 클라이언트로 예외정보를 전달하는 클래스로 ExceptionResponse를 사용하고 있다.

 

package pe.pilseong.footballserver.exception;

import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;

import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import lombok.extern.slf4j.Slf4j;

@ControllerAdvice
@Slf4j
public class CustomGlobalExceptionHandler extends ResponseEntityExceptionHandler {

  // Custom exception
  @ExceptionHandler(value = SkillNotFoundException.class)
  public ResponseEntity<Map<String, String>> skillNotFoundException(SkillNotFoundException ex, 
    WebRequest reqeust) {
    log.info("SkillNotFoundException is concerned");
    return notFoundHandler(ex);

  }

  // for DeleteMapping
  // updateSkill when not a valid enum type
  @ExceptionHandler(value = EmptyResultDataAccessException.class)
  public ResponseEntity<Map<String, String>> deleteTargetNotFoundHandler(EmptyResultDataAccessException ex,
      WebRequest reqeust) {
    log.info("EmptyResultDataAccessException is concerned");
    return notFoundHandler(ex);
  }

  // to check valid enum type when udpating
  @ExceptionHandler(value = IllegalArgumentException.class)
  public ResponseEntity<Map<String, String>> illegalArgumentExceptionHandler(IllegalArgumentException ex,
      WebRequest reqeust) {
    log.info("illegalArgumentExceptionHandler is concerned");
    Map<String, String> body = new LinkedHashMap<>();
    body.put("timestamp", new Date().toString());
    body.put("status", HttpStatus.BAD_REQUEST.toString());
    body.put("errors", ex.getMessage());

    return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
  }

  private ResponseEntity<Map<String, String>> notFoundHandler(RuntimeException ex) {
    Map<String, String> body = new LinkedHashMap<>();
    body.put("timestamp", new Date().toString());
    body.put("status", HttpStatus.NOT_FOUND.toString());
    body.put("errors", ex.getMessage());

    return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
  }

  // @Validated @Min check - to check index is at least larger than 0
  @ExceptionHandler({ ConstraintViolationException.class })
  public ResponseEntity<Object> handleConstraintViolation(ConstraintViolationException ex, 
    WebRequest request) {
    log.info("ConstraintViolationException is concerned");
    List<String> errors = new ArrayList<String>();
    for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
      errors.add(
          violation.getRootBeanClass().getName() + " " + violation.getPropertyPath() + ": "
            + violation.getMessage());
    }
    ExceptionResponse apiError = new ExceptionResponse(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(),
        new Date().toString(), errors);
    return new ResponseEntity<Object>(apiError, new HttpHeaders(), apiError.getStatus());
  }

  // to check required fields are missing
  @Override
  protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, 
    HttpHeaders headers, HttpStatus status, WebRequest request) {

    log.info("handleMethodArgumentNotValid method is invoked");

    Map<String, Object> body = new LinkedHashMap<>();
    body.put("timestamp", new Date().toString());
    body.put("status", status.value());

    List<String> errors = ex.getBindingResult().getFieldErrors().stream().map(error -> error.getDefaultMessage())
        .collect(Collectors.toList());

    body.put("errors", errors);

    return new ResponseEntity<>(body, headers, status);
  }

  // @Valid check to avoid having invalid enum type when posting
  @Override
  protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex, 
    HttpHeaders headers, HttpStatus status, WebRequest request) {

    log.info("handleHttpMessageNotReadable method is invoked");

    Map<String, Object> body = new LinkedHashMap<>();
    body.put("timestamp", new Date().toString());
    body.put("status", status.value());

    String error = ex.getMostSpecificCause().getMessage();

    body.put("error", error);

    return new ResponseEntity<>(body, headers, status);
  }

}

 

 

  6-3 에러를 담아 전달하는 클래스로 ExceptionResponse로 작성하였다.

 

package pe.pilseong.footballserver.exception;

import java.util.Arrays;
import java.util.List;

import org.springframework.http.HttpStatus;

import lombok.Data;


@Data
public class ExceptionResponse {
 
  private HttpStatus status;
  private String message;
  private String timestamp;
  private List<String> errors;

  public ExceptionResponse(HttpStatus status, String message, String timestamp, List<String> errors) {
      super();
      this.status = status;
      this.message = message;
      this.timestamp = timestamp;
      this.errors = errors;
  }

  public ExceptionResponse(HttpStatus status, String message, String timestamp, String error) {
      super();
      this.status = status;
      this.message = message;
      this.timestamp = timestamp;
      errors = Arrays.asList(error);
  }
}

 

  6-4 Skill을 데이터베이스에서 찾지 못할 경우 발생시키는 SkillNotFoundException 클래스 작성

 

package pe.pilseong.footballserver.exception;

public class SkillNotFoundException extends RuntimeException {
  private static final long serialVersionUID = 1L;

  public SkillNotFoundException(Long id) {
    super("Skill id :: '" + id + "' not found" );
  }
}
728x90
댓글