[Jersey] 6. CRUD API 생성 및 Validation 설정하기

2022. 5. 16. 18:02Spring/Jersey

반응형
  1. 설계
  2. Validation 적용
  3. User Entity 수정
  4. Service 수정
  5. Endpoint 수정
  6. Validation 관련 에러 핸들링
    1. BV_SEND_ERROR_IN_RESPONSE 프로퍼티 설정
    2. ExceptionMapper<ValidationException> 구현 클래스 정의
  7. 결과

1. 설계

User 추가, User 수정 api를 만들어봅시다.

추가와 수정 api는 조회와 달리, DB에 있는 데이터를 변동시키는 작업이기 때문에 validation 설정이 필요합니다.

각 api 는 아래와 같은 규칙을 따릅니다.

  • POST /v1/users
    • name: 한글 또는 영어 2~10자
    • email: 이메일 형식
    • type: BASIC 또는 OWNER
    • sex : M 또는 F
    • birthDate: yyyy-MM-dd 형식
    • phoneNumber: 휴대전화 형식 (- 없이 표기)
    • name, email, type, sex, birthDate, phoneNumber 모두 null 또는 빈문자열 입력 허용 하지 않으며, 모든 값을 다 입력해야한다.
  • PATCH /v1/users/{userId}
    • email
    • type: BASIC 또는 OWNER
    • phoneNumber: 휴대전화 형식 (- 없이 표기)
    • email, type, phoneNumber 값은 모두 optional, 설정한 값만 업데이트 된다.
    • 빈문자열 입력시 업데이트 되지 않는다.

2. Validation 적용

Spring 프로젝트에서 Validation 제약조건을 설정하는 방법은 다양하게 존재하지만, 가장 간단한 방법은 javax.validation.constraints 을 활용하여 VO 객체 내의 필드값에 @Pattern이나 @NotBlank 을 설정하는 것입니다.

새로 이용할 VO 데이터 클래스를 설정하기 전에, 기존에 만들었던 UserValue 클래스를 수정변경해봅니다.
기존의 record 데이터를 UserValue.Response 내부로 옮기고, 이름도 수정했습니다.

  • UserValue.UserData -> UserValue.Response.Detail
  • UserValue.UserSimpleData -> UserValue.Response.Simple

새로 변경된 UserValue 클래스입니다.
public final class UserValue {

    public static final class Response {
        public record Detail(Long id, String name, String email, String type, String sex, String birthDate, String phoneNumber, String createdAt, String updatedAt) {
            public Detail {
                Objects.requireNonNull(id);
                Objects.requireNonNull(name);
                Objects.requireNonNull(email);
            }

            public static Detail of(User u) {
                return new Detail(u.getId(), u.getName(), u.getEmail(), u.getType(), u.getSex(),
                        Optional.ofNullable(u.getBirthDate()).map(m -> m.format(DateTimeUtils.DATE_FORMATTER)).orElse(null),
                        u.getPhoneNumber(),
                        Optional.ofNullable(u.getCreatedAt()).map(m -> m.format(DateTimeUtils.DATE_TIME_FORMATTER)).orElse(null),
                        Optional.ofNullable(u.getUpdatedAt()).map(m -> m.format(DateTimeUtils.DATE_TIME_FORMATTER)).orElse(null));
            }
        }

        public record Simple(Long id, String name, String email, String type, String sex) {
            public Simple {
                Objects.requireNonNull(id);
                Objects.requireNonNull(name);
                Objects.requireNonNull(email);
            }

            public static Simple of(User u) {
                return new Simple(u.getId(), u.getName(), u.getEmail(), u.getType(), u.getSex());
            }
        }
    }

}

여기에, User 추가 및 User 수정에 이용될 record 데이터 클래스를 추가해봅니다.

package xyz.applebox.jersey.domain.value;

import xyz.applebox.jersey.domain.entity.User;
import xyz.applebox.jersey.util.DateTimeUtils;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import java.util.Objects;
import java.util.Optional;

public final class UserValue {
    public static final class Request {
        public record Creation(
                @NotBlank @Pattern(regexp = "^[a-zA-Z가-힣]{2,10}$") String name,
                @NotBlank @Email String email,
                @NotBlank String password,
                @NotBlank @Pattern(regexp = "^(BASIC|OWNER)$") String type,
                @NotBlank @Pattern(regexp = "^[MF]$") String sex,
                @NotBlank @Pattern(regexp = "^(19[0-9][0-9]|20\\d{2})-(0[0-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])$") String birthDate,
                @NotBlank @Pattern(regexp = "^01[0179][0-9]{7,8}$") String phoneNumber
        ) {}

        public record Patch(
                @Email String email,
                String password,
                @Pattern(regexp = "^(BASIC|OWNER)$") String type,
                @Pattern(regexp = "^01[0179][0-9]{7,8}$") String phoneNumber
        ) {}
    }

    public static final class Response {
        ...
    }

}

생성에 이용되는 Data 클래스의 이름은 Creation, 수정에 이용될 Data 클래스 이름을 Patch 로 설정했습니다.

@Pattern을 이용하면 RegExp을 쉽게 설정할 수 있습니다.


3. User Entity 수정

User 추가는 입력받은 UserValue.Request.Creation를 이용하여, map 변환을 이용하여 User로 변환한 후 userRepository.save로 저장할 것입니다.

User로 변환하거나, 수정되는 일련의 과정들은 User 엔티티 내에 정의하여, UserService를 간결히 표현할 것입니다.

User 엔티티에는 아래의 형식을 맞춰 메서드를 정의합니다.

  1. createAt, active 값은 Entity Listener를 이용하여 persist 할때 자동으로 설정합니다.
  2. updateAt 도 Entity Listener를 이용하여 merge 할 때 자동으로 업데이트 합니다.
  3. UserValue.Request.Creation 객체를 이용하여 User 객체를 리턴하는 static 메서드를 만듭니다.
  4. UserValue.Request.Patch 객체 내의 각 필드값이 null또는 빈문자열이 아닐 경우 User 객체의 각 필드값을 업데이트하는 메서드를 만듭니다.

@NoArgsConstructor
@DynamicInsert
@DynamicUpdate
@Getter
@Setter
@Entity
@Table(indexes = {@Index(name = "UK_USER_EMAIL", columnList = "email", unique = true)})
@Where(clause = "active = true")
public class User {

    ...

    @Builder
    private User(String type, String email, String name, String sex, LocalDate birthDate, String phoneNumber, String password) {
        this.type = type;
        this.email = email;
        this.name = name;
        this.sex = sex;
        this.birthDate = birthDate;
        this.phoneNumber = phoneNumber;
        this.password = password;
    }

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.active = true;
    }

    @PreUpdate
    protected void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    public static User of(@NotNull UserValue.Request.Creation u) {
        return builder().type(u.type()).email(u.email()).name(u.name()).sex(u.sex())
                .birthDate(Optional.ofNullable(u.birthDate())
                        .map(birthDate -> LocalDate.parse(birthDate, DateTimeUtils.DATE_FORMATTER))
                        .orElse(null))
                .phoneNumber(u.phoneNumber()).password(u.password()).build();
    }

    public void patch(UserValue.Request.Patch u) {
        if(Strings.isNotBlank(u.email())) this.email = u.email();
        if(Strings.isNotBlank(u.password())) this.password = u.password();
        if(Strings.isNotBlank(u.phoneNumber())) this.phoneNumber = u.phoneNumber();
        if(Strings.isNotBlank(u.type())) this.type = u.type();
    }
}

4. Service 수정

User 객체를 생성하거나 수정하는 부분을 User 엔티티로 뺐기 때문에 Service는 매우 단순해집니다.

Patch 기능의 경우, User 단건 조회에서 이용했던 userRepository.findById 를 동일하게 이용합니다.

중복되는 구조가 존재하니 getUser라는 메서드로 빼서 중복을 줄입니다.

patch와 save 기능은 모두 DB의 데이터를 손대는 기능이기 때문에 메서드에 @Transactional 을 설정해야합니다.

save메서드는 void로 설정해도 무방하나, 여기에서는 Response Location Header에 생성된 resource에 대한 uri 를 설정할 예정이므로 id를 내보내도록 했습니다.

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;

    ...

    @Transactional
    public Long save(UserValue.Request.Creation data) {
        User user = User.of(data);
        userRepository.save(user);
        return user.getId();
    }

    public UserValue.Response.Detail findById(long id) {
        return UserValue.Response.Detail.of(getUser(id));
    }

    @Transactional
    public void patch(Long id, UserValue.Request.Patch data) {
        User user = getUser(id);
        user.patch(data);
        userRepository.save(user);
    }

    User getUser(long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new BadRequestException(MessageUtils.INVALID_USER));
    }
}

5. Endpoint 수정

아래와 같은 규칙으로 Endpoint를 정의해봅니다.

  • User 추가
    • POST method를 이용
    • UserValue.Request.Creation에 설정된 validation을 활용하기 위해 @Valid를 설정
    • 추가 성공시 201 HTTP status code (Created)
    • 새로 추가된 user의 id를 포함한 user 조회 api를 Location헤더에 설정함
      • ex. http://localhost:8080/v1/users/6
  • User 패치
    • PATCH method를 이용
    • UserValue.Request.Patch에 설정된 validation을 활용하기 위해 @Valid를 설정
    • 업데이트 성공시 204 HTTP status code(= No Content)

package xyz.applebox.jersey.endpoint.v1;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import xyz.applebox.jersey.domain.value.DataResponse;
import xyz.applebox.jersey.domain.value.UserValue;
import xyz.applebox.jersey.service.UserService;

import javax.validation.Valid;
import javax.ws.rs.*;
import javax.ws.rs.core.*;
import java.util.List;

@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
@Component
@Path("/users")
public final class UserEndpoint {

    private final UserService userService;
    @Context
    private UriInfo uriInfo;

...

    @POST
    public Response save(@Valid UserValue.Request.Creation data) {
        Long userId = userService.save(data);
        return Response.created(UriBuilder.fromUri(uriInfo.getAbsolutePath()).path("{userId}").build(userId)).build();
    }

    @PATCH
    @Path("/{userId}")
    public Response patch(@PathParam("userId") long userId, @Valid UserValue.Request.Patch data) {
        userService.patch(userId, data);
        return Response.noContent().build();
    }

}

6. Validation 관련 에러 핸들링

방법 1. BV_SEND_ERROR_IN_RESPONSE 프로퍼티 설정

javax.validation 패키지를 활용한 제약조건 관련하여 에러가 발생했을 시, 우리가 상상했던 에러 Response가 아닌 아래와 같은 형태의 응답이 내려옵니다.

03-1


JAX-RS 프로젝트에서는 ResourceConfig에서 property(ServerProperties.BV_SEND_ERROR_IN_RESPONSE, true); 를 설정할 경우 javax.validation 관련 에러 발생시 List 형식으로 에러가 발생된 원인을 출력해줍니다.

@Component
@ApplicationPath("/v1")
public class ResourceV1Config extends ResourceConfig {

    public ResourceV1Config() {
        packages("xyz.applebox.jersey.endpoint.v1", "xyz.applebox.jersey.config.provider");
        property(ServerProperties.BV_SEND_ERROR_IN_RESPONSE, true);
    }

}

03-6

ServerProperties.BV_SEND_ERROR_IN_RESPONSE 프로퍼티를 true로 설정 한 후의 Response화면

그렇지만 위의 Response는 우리가 정의했던 BaseResponse 구조와 다릅니다.


 

방법 2. ExceptionMapper<ValidationException> 구현 클래스 정의

위와같이 Response가 출력되는 이유는 Jersey 프레임워크에서 ValidationException에 대한 ExceptionMapper를 정의해뒀기 때문입니다.

03-2

만일 Jersey에서 제공해주는 ExceptionMapper를 그대로 활용할거라면, 방법 1 에서 제시했던 것처럼, ResourceConfig에 프로퍼티 설정을 하면 됩니다.


그러나, 기존에 정의했던것 처럼 새로운 형식으로 Response를 구성하고 싶다면 이전시간에 했었던 것 처럼 ExceptionMapper 클래스를 새로 정의해주면 됩니다.

package xyz.applebox.jersey.config.provider;

import xyz.applebox.jersey.domain.value.BaseResponse;

import javax.validation.ValidationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;

@Provider
public class ValidationExceptionMapper implements ExceptionMapper<ValidationException> {

    @Override
    public Response toResponse(ValidationException exception) {
        int status = Response.Status.BAD_REQUEST.getStatusCode();
        Object entity = BaseResponse.of(exception.getMessage());

        return Response.status(status)
                .type(MediaType.APPLICATION_JSON_TYPE)
                .entity(entity).build();
    }
}

새로 정의한 ExceptionMapper를 이용한 Response 결과는 아래와 같습니다.

03-7


7. 결과

7.1. User 추가

03-5


7.2. User 수정

03-3

03-4


※ GitHub에서 jersey 프로젝트(v1.0.5)를 다운받아 볼 수 있습니다.


++

  • REST API CRUD with Jersey
  • REST API Validation with Jersey
728x90
반응형