2022. 5. 16. 18:02ㆍSpring/Jersey
- 설계
- Validation 적용
- User Entity 수정
- Service 수정
- Endpoint 수정
- Validation 관련 에러 핸들링
- 결과
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}
- 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 엔티티에는 아래의 형식을 맞춰 메서드를 정의합니다.
- createAt, active 값은 Entity Listener를 이용하여 persist 할때 자동으로 설정합니다.
- updateAt 도 Entity Listener를 이용하여 merge 할 때 자동으로 업데이트 합니다.
UserValue.Request.Creation
객체를 이용하여 User 객체를 리턴하는 static 메서드를 만듭니다.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
- ex.
- 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가 아닌 아래와 같은 형태의 응답이 내려옵니다.
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); } }
ServerProperties.BV_SEND_ERROR_IN_RESPONSE
프로퍼티를 true로 설정 한 후의 Response화면
그렇지만 위의 Response는 우리가 정의했던 BaseResponse 구조와 다릅니다.
방법 2. ExceptionMapper<ValidationException> 구현 클래스 정의
위와같이 Response가 출력되는 이유는 Jersey 프레임워크에서 ValidationException에 대한 ExceptionMapper를 정의해뒀기 때문입니다.
만일 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 결과는 아래와 같습니다.
7. 결과
7.1. User 추가
7.2. User 수정
※ GitHub에서 jersey 프로젝트(v1.0.5)를 다운받아 볼 수 있습니다.
++
- REST API CRUD with Jersey
- REST API Validation with Jersey
'Spring > Jersey' 카테고리의 다른 글
[Jersey] 5. ExceptionMapper를 이용한 전역 Error Handling (0) | 2022.05.13 |
---|---|
[Jersey] 4. Response 전용 VO 이용하기 (0) | 2022.05.12 |
[Jersey] 3. WebApplicationException 상속 클래스를 이용한 Error Handling (0) | 2022.05.12 |
[Jersey] 2. JPA 및 datasource 설정하기 (0) | 2022.05.11 |
[Jersey] 1. Jersey 프로젝트 생성하기 (0) | 2022.05.10 |