diff --git a/src/main/java/contest/collectingbox/global/config/WebConfig.java b/src/main/java/contest/collectingbox/global/config/WebConfig.java index 8d18a0c..54f7df0 100644 --- a/src/main/java/contest/collectingbox/global/config/WebConfig.java +++ b/src/main/java/contest/collectingbox/global/config/WebConfig.java @@ -1,11 +1,17 @@ package contest.collectingbox.global.config; +import contest.collectingbox.global.config.web.PointArgumentResolver; +import contest.collectingbox.global.config.web.TagsArgumentResolver; import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.List; + @Configuration public class WebConfig implements WebMvcConfigurer { + @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") @@ -13,4 +19,10 @@ public void addCorsMappings(CorsRegistry registry) { .allowedMethods("*") .maxAge(3000); } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new PointArgumentResolver()); + resolvers.add(new TagsArgumentResolver()); + } } diff --git a/src/main/java/contest/collectingbox/global/config/web/PointArgumentResolver.java b/src/main/java/contest/collectingbox/global/config/web/PointArgumentResolver.java new file mode 100644 index 0000000..beb337c --- /dev/null +++ b/src/main/java/contest/collectingbox/global/config/web/PointArgumentResolver.java @@ -0,0 +1,45 @@ +package contest.collectingbox.global.config.web; + +import contest.collectingbox.global.exception.CollectingBoxException; +import contest.collectingbox.global.exception.ErrorCode; +import contest.collectingbox.global.utils.GeometryUtil; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.coyote.BadRequestException; +import org.locationtech.jts.geom.Point; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class PointArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(Point.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + + if (request.getParameter("latitude") == null) { + throw new MissingServletRequestParameterException("latitude", "Point"); + } + if (request.getParameter("longitude") == null) { + throw new MissingServletRequestParameterException("longitude", "Point"); + } + + double latitude; + double longitude; + + try { + latitude = Double.parseDouble(request.getParameter("latitude")); + longitude = Double.parseDouble(request.getParameter("longitude")); + return GeometryUtil.toPoint(longitude, latitude); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid parameter value for latitude or longitude"); + } + } +} diff --git a/src/main/java/contest/collectingbox/global/config/web/TagsArgumentResolver.java b/src/main/java/contest/collectingbox/global/config/web/TagsArgumentResolver.java new file mode 100644 index 0000000..0f987f6 --- /dev/null +++ b/src/main/java/contest/collectingbox/global/config/web/TagsArgumentResolver.java @@ -0,0 +1,39 @@ +package contest.collectingbox.global.config.web; + +import contest.collectingbox.module.collectingbox.domain.Tag; +import contest.collectingbox.module.collectingbox.domain.Tags; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.Arrays; +import java.util.List; + +public class TagsArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(Tags.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + if (request.getParameter("tags") == null) { + throw new MissingServletRequestParameterException("tags", "Tags"); + } + + try { + List tags = Arrays.stream(request.getParameter("tags").split(",")) + .map(Tag::valueOf) + .toList(); + return new Tags(tags); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid parameter value for tags"); + } + } +} diff --git a/src/main/java/contest/collectingbox/global/exception/ErrorCode.java b/src/main/java/contest/collectingbox/global/exception/ErrorCode.java index 4034358..39635ef 100644 --- a/src/main/java/contest/collectingbox/global/exception/ErrorCode.java +++ b/src/main/java/contest/collectingbox/global/exception/ErrorCode.java @@ -14,8 +14,9 @@ public enum ErrorCode { NOT_SELECTED_TAG(BAD_REQUEST, "수거함 태그는 반드시 한 개 이상 설정해야 합니다."), INVALID_REVIEW_CONTENT(BAD_REQUEST, "올바르지 않는 리뷰 내용입니다."), INVALID_BEAN(BAD_REQUEST, "유효하지 않은 데이터입니다."), - MISSING_REQUEST_PARAM(BAD_REQUEST, "필수 요청 파라미터가 존재하지 않습니다."), + MISSING_REQUEST_PARAM(BAD_REQUEST, "'%s' 타입의 '%s' 요청 파라미터가 존재하지 않습니다."), MISMATCH_REQUEST_PARAM(BAD_REQUEST, "요청 파라미터가 유효하지 않습니다."), + ILLEGAL_ARGUMENT(BAD_REQUEST, "유효하지 않은 값입니다."), // 404 NOT_FOUND_COLLECTING_BOX(NOT_FOUND, "해당 수거함이 존재하지 않습니다."), diff --git a/src/main/java/contest/collectingbox/global/exception/GlobalExceptionHandler.java b/src/main/java/contest/collectingbox/global/exception/GlobalExceptionHandler.java index 2747ee7..31254db 100644 --- a/src/main/java/contest/collectingbox/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/contest/collectingbox/global/exception/GlobalExceptionHandler.java @@ -30,7 +30,8 @@ public ApiResponse methodValidException(MethodArgumentNotValidException @ExceptionHandler(MissingServletRequestParameterException.class) @ResponseStatus(BAD_REQUEST) public ApiResponse handleMissingParams(MissingServletRequestParameterException e) { - return ApiResponse.error(MISSING_REQUEST_PARAM, MISSING_REQUEST_PARAM.getMessage()); + return ApiResponse.error(MISSING_REQUEST_PARAM, + String.format(MISSING_REQUEST_PARAM.getMessage(), e.getParameterType(), e.getParameterName())); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) @@ -38,4 +39,10 @@ public ApiResponse handleMissingParams(MissingServletRequestParameterExc public ApiResponse handleMissingParams(MethodArgumentTypeMismatchException e) { return ApiResponse.error(MISMATCH_REQUEST_PARAM, MISSING_REQUEST_PARAM.getMessage()); } + + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(BAD_REQUEST) + public ApiResponse handleIllegalArgument(IllegalArgumentException e) { + return ApiResponse.error(ILLEGAL_ARGUMENT, e.getMessage()); + } } diff --git a/src/main/java/contest/collectingbox/module/collectingbox/application/CollectingBoxService.java b/src/main/java/contest/collectingbox/module/collectingbox/application/CollectingBoxService.java index 17012e0..025f252 100644 --- a/src/main/java/contest/collectingbox/module/collectingbox/application/CollectingBoxService.java +++ b/src/main/java/contest/collectingbox/module/collectingbox/application/CollectingBoxService.java @@ -1,11 +1,9 @@ package contest.collectingbox.module.collectingbox.application; -import contest.collectingbox.global.exception.CollectingBoxException; -import contest.collectingbox.global.exception.ErrorCode; -import contest.collectingbox.global.utils.GeometryUtil; import contest.collectingbox.module.collectingbox.domain.CollectingBox; import contest.collectingbox.module.collectingbox.domain.CollectingBoxRepository; import contest.collectingbox.module.collectingbox.domain.Tag; +import contest.collectingbox.module.collectingbox.domain.Tags; import contest.collectingbox.module.collectingbox.dto.CollectingBoxDetailResponse; import contest.collectingbox.module.collectingbox.dto.CollectingBoxResponse; import contest.collectingbox.module.location.domain.DongInfo; @@ -19,8 +17,6 @@ import java.util.List; import java.util.stream.Collectors; -import static contest.collectingbox.global.exception.ErrorCode.NOT_FOUND_COLLECTING_BOX; - @Service @RequiredArgsConstructor public class CollectingBoxService { @@ -32,16 +28,9 @@ public class CollectingBoxService { private int radius; @Transactional(readOnly = true) - public List findCollectingBoxesWithinArea(final Double latitude, - final Double longitude, - final List tags) { - if (tags.isEmpty()) { - throw new CollectingBoxException(ErrorCode.NOT_SELECTED_TAG); - } - - Point center = GeometryUtil.toPoint(longitude, latitude); - - return collectingBoxRepository.findAllWithinArea(center, radius, tags) + public List findCollectingBoxesWithinArea(final Point center, + final Tags tags) { + return collectingBoxRepository.findAllWithinArea(center, radius, tags.toUnmodifiableList()) .stream() .map(CollectingBoxResponse::fromEntity) .collect(Collectors.toList()); @@ -53,25 +42,21 @@ public CollectingBoxDetailResponse findBoxDetailById(Long collectionId) { } @Transactional(readOnly = true) - public List searchCollectingBoxes(final String query, final List tags) { - if (tags.isEmpty()) { - throw new CollectingBoxException(ErrorCode.NOT_SELECTED_TAG); - } - + public List searchCollectingBoxes(final String query, final Tags tags) { // '강남구' if (query.endsWith("구")) { - return searchBySigunguNm(query, tags); + return searchBySigunguNm(query, tags.toUnmodifiableList()); } String[] splitQuery = query.split(" "); // '역삼1동' if (splitQuery.length == 1) { - return searchByDongNm(query, tags); + return searchByDongNm(query, tags.toUnmodifiableList()); } // '강남구 역삼1동' - return searchByDongNm(splitQuery[1], tags); + return searchByDongNm(splitQuery[1], tags.toUnmodifiableList()); } private List searchBySigunguNm(String query, List tags) { diff --git a/src/main/java/contest/collectingbox/module/collectingbox/domain/Tags.java b/src/main/java/contest/collectingbox/module/collectingbox/domain/Tags.java new file mode 100644 index 0000000..e74cb60 --- /dev/null +++ b/src/main/java/contest/collectingbox/module/collectingbox/domain/Tags.java @@ -0,0 +1,22 @@ +package contest.collectingbox.module.collectingbox.domain; + +import contest.collectingbox.global.exception.CollectingBoxException; +import contest.collectingbox.global.exception.ErrorCode; + +import java.util.Collections; +import java.util.List; + +public class Tags { + private final List tags; + + public Tags(List tags) { + if (tags == null || tags.isEmpty()) { + throw new CollectingBoxException(ErrorCode.NOT_SELECTED_TAG); + } + this.tags = tags; + } + + public List toUnmodifiableList() { + return Collections.unmodifiableList(tags); + } +} diff --git a/src/main/java/contest/collectingbox/module/collectingbox/presentation/CollectingBoxController.java b/src/main/java/contest/collectingbox/module/collectingbox/presentation/CollectingBoxController.java index 250d68b..2b2f79b 100644 --- a/src/main/java/contest/collectingbox/module/collectingbox/presentation/CollectingBoxController.java +++ b/src/main/java/contest/collectingbox/module/collectingbox/presentation/CollectingBoxController.java @@ -2,12 +2,12 @@ import contest.collectingbox.global.common.ApiResponse; import contest.collectingbox.module.collectingbox.application.CollectingBoxService; -import contest.collectingbox.module.collectingbox.domain.Tag; +import contest.collectingbox.module.collectingbox.domain.Tags; import contest.collectingbox.module.collectingbox.dto.CollectingBoxDetailResponse; import contest.collectingbox.module.collectingbox.dto.CollectingBoxResponse; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import lombok.RequiredArgsConstructor; +import org.locationtech.jts.geom.Point; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -22,16 +22,15 @@ public class CollectingBoxController { @Operation(summary = "수거함 목록 조회", description = "위도와 경도를 기준으로 200m 반경에 위치한 수거함 목록을 조회합니다.") @GetMapping - public ApiResponse> findCollectingBoxesWithinArea(@RequestParam final Double latitude, - @RequestParam final Double longitude, - @RequestParam final List tags) { - return ApiResponse.ok(collectingBoxService.findCollectingBoxesWithinArea(latitude, longitude, tags)); + public ApiResponse> findCollectingBoxesWithinArea(final Point center, + final Tags tags) { + return ApiResponse.ok(collectingBoxService.findCollectingBoxesWithinArea(center, tags)); } @Operation(summary = "지역별 수거함 검색", description = "구/동 단위로 검색한 주소에 위치한 수거함 목록을 조회합니다.") @GetMapping("/search") public ApiResponse> searchCollectingBoxes(@RequestParam final String query, - @RequestParam final List tags) { + final Tags tags) { return ApiResponse.ok(collectingBoxService.searchCollectingBoxes(query, tags)); } diff --git a/src/test/java/contest/collectingbox/module/collectingbox/application/CollectingBoxServiceTest.java b/src/test/java/contest/collectingbox/module/collectingbox/application/CollectingBoxServiceTest.java index e428ba2..f5e5ca5 100644 --- a/src/test/java/contest/collectingbox/module/collectingbox/application/CollectingBoxServiceTest.java +++ b/src/test/java/contest/collectingbox/module/collectingbox/application/CollectingBoxServiceTest.java @@ -5,7 +5,7 @@ import contest.collectingbox.global.utils.GeometryUtil; import contest.collectingbox.module.collectingbox.domain.CollectingBox; import contest.collectingbox.module.collectingbox.domain.CollectingBoxRepository; -import contest.collectingbox.module.collectingbox.domain.Tag; +import contest.collectingbox.module.collectingbox.domain.Tags; import contest.collectingbox.module.collectingbox.dto.CollectingBoxDetailResponse; import contest.collectingbox.module.collectingbox.dto.CollectingBoxResponse; import contest.collectingbox.module.location.domain.Location; @@ -23,7 +23,7 @@ import java.util.Collections; import java.util.List; -import static contest.collectingbox.global.exception.ErrorCode.*; +import static contest.collectingbox.global.exception.ErrorCode.NOT_FOUND_COLLECTING_BOX; import static contest.collectingbox.module.collectingbox.domain.Tag.*; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; @@ -32,9 +32,6 @@ @ExtendWith(MockitoExtension.class) class CollectingBoxServiceTest { - private final double LATITUDE = 37.4888953606578; - private final double LONGITUDE = 126.901185398046; - private Point center; @Value("${collecting-box.search.radius.meter}") @@ -48,14 +45,14 @@ class CollectingBoxServiceTest { @BeforeEach void setUp() { - center = GeometryUtil.toPoint(LONGITUDE, LATITUDE); + center = GeometryUtil.toPoint(126.901185398046, 37.4888953606578); } @Test @DisplayName("위도와 경도를 기준으로 특정 반경에 위치한 수거함 목록 조회 성공") void findCollectingBoxesWithinArea_Success_withinArea() { // given - List tags = List.of(CLOTHES, LAMP_BATTERY, MEDICINE, TRASH); + Tags tags = new Tags(List.of(CLOTHES, LAMP_BATTERY, MEDICINE, TRASH)); CollectingBox box = CollectingBox.builder() .id(1L) @@ -64,11 +61,11 @@ void findCollectingBoxesWithinArea_Success_withinArea() { .build(); // when - when(collectingBoxRepository.findAllWithinArea(center, radius, tags)).thenReturn( + when(collectingBoxRepository.findAllWithinArea(center, radius, tags.toUnmodifiableList())).thenReturn( Collections.singletonList(box)); List result = - collectingBoxService.findCollectingBoxesWithinArea(LATITUDE, LONGITUDE, tags); + collectingBoxService.findCollectingBoxesWithinArea(center, tags); // then assertThat(result.get(0).getId()).isEqualTo(box.getId()); @@ -79,7 +76,7 @@ void findCollectingBoxesWithinArea_Success_withinArea() { void findCollectingBoxesWithinArea_Fail_ByTagIsEmpty() { // when, then Assertions.assertThatThrownBy( - () -> collectingBoxService.findCollectingBoxesWithinArea(LATITUDE, LONGITUDE, List.of())) + () -> collectingBoxService.findCollectingBoxesWithinArea(center, new Tags(List.of()))) .isInstanceOf(CollectingBoxException.class) .hasMessageContaining(ErrorCode.NOT_SELECTED_TAG.getMessage()); }