diff --git a/gradle.properties b/gradle.properties index a8bc6e7..0966cf3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ ktlintVersion=11.0.0 springBootVersion=2.7.11 springDependencyManagementVersion=1.0.15.RELEASE # project -applicationVersion=0.5.1 +applicationVersion=0.6.0 projectGroup=com.mealkitary # test kotestVersion=4.4.3 diff --git a/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/main/kotlin/com/mealkitary/address/KakaoApiAddressResolver.kt b/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/main/kotlin/com/mealkitary/address/KakaoApiAddressResolver.kt index e510e01..c4e4de7 100644 --- a/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/main/kotlin/com/mealkitary/address/KakaoApiAddressResolver.kt +++ b/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/main/kotlin/com/mealkitary/address/KakaoApiAddressResolver.kt @@ -1,5 +1,8 @@ package com.mealkitary.address +import com.mealkitary.address.payload.Document +import com.mealkitary.address.payload.Meta +import com.mealkitary.address.payload.RoadAddress import com.mealkitary.common.model.Address import com.mealkitary.common.model.Coordinates import com.mealkitary.shop.domain.shop.ShopAddress @@ -7,6 +10,11 @@ import com.mealkitary.shop.domain.shop.factory.AddressResolver import org.springframework.context.annotation.Primary import org.springframework.stereotype.Component +private const val INVALID_ROAD_ADDR_MESSAGE = "올바른 도로명 주소를 입력해주세요." +private const val INVALID_COORDINATE_FORMAT = "유효하지 않은 좌표 범위입니다." +private const val BUILDING_NO_DELIMITER = "-" +private const val VALID_ADDR_TYPE = "ROAD_ADDR" + @Primary @Component class KakaoApiAddressResolver( @@ -14,26 +22,58 @@ class KakaoApiAddressResolver( ) : AddressResolver { override fun resolve(fullAddress: String): ShopAddress { - val kakaoApiAddressResponse = kakaoApiWebClient.requestAddress(fullAddress) + val (documents, meta) = kakaoApiWebClient.requestAddress(fullAddress, "https://dapi.kakao.com") + checkHasDocument(meta, documents) + val document = findFirstMatchedDocument(documents) - val (x, y, address, roadAddress) = kakaoApiAddressResponse.document + return ShopAddress.of(resolveCityCode(document), resolveCoordinates(document), resolveAddress(document)) + } - val (longitude, latitude) = listOf(x, y).map { - it.toDoubleOrNull() ?: throw IllegalArgumentException("유효하지 않은 좌표 범위입니다.") + private fun checkHasDocument(meta: Meta, documents: List) { + if (meta.total_count < 0 || documents.isEmpty()) { + throw IllegalArgumentException(INVALID_ROAD_ADDR_MESSAGE) } + } + + private fun findFirstMatchedDocument(documents: List): Document { + val firstMatchedDocument = documents[0] + checkIsValidAddressType(firstMatchedDocument) + + return firstMatchedDocument + } + + private fun checkIsValidAddressType(addressDocument: Document) { + if (addressDocument.address_type != VALID_ADDR_TYPE) { + throw IllegalArgumentException(INVALID_ROAD_ADDR_MESSAGE) + } + } + + private fun resolveCityCode(document: Document): String { + requireNotNull(document.address) { INVALID_ROAD_ADDR_MESSAGE } + + return document.address.h_code + } + + private fun resolveCoordinates(loadAddressDocument: Document): Coordinates { + val (longitude, latitude) = listOf(loadAddressDocument.x, loadAddressDocument.y).map { + it.toDoubleOrNull() ?: throw IllegalArgumentException(INVALID_COORDINATE_FORMAT) + } + + return Coordinates.of(longitude, latitude) + } + + private fun resolveAddress(document: Document): Address { + val roadAddress: RoadAddress = requireNotNull(document.road_address) { INVALID_ROAD_ADDR_MESSAGE } + val roadName = + "${roadAddress.road_name} ${roadAddress.main_building_no}-${roadAddress.sub_building_no}".trim() - return ShopAddress.of( - roadAddress.h_code, - Coordinates.of( - longitude, - latitude - ), - Address.of( - address.region_1depth_name, - address.region_2depth_name, - address.region_3depth_name, - roadAddress.road_name - ) + return Address.of( + roadAddress.region_1depth_name, + roadAddress.region_2depth_name, + roadAddress.region_3depth_name, + if (roadName.endsWith(BUILDING_NO_DELIMITER)) { + roadName.replace(BUILDING_NO_DELIMITER, "") + } else roadName ) } } diff --git a/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/main/kotlin/com/mealkitary/address/KakaoApiWebClient.kt b/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/main/kotlin/com/mealkitary/address/KakaoApiWebClient.kt index 6cc8c9d..452eeac 100644 --- a/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/main/kotlin/com/mealkitary/address/KakaoApiWebClient.kt +++ b/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/main/kotlin/com/mealkitary/address/KakaoApiWebClient.kt @@ -4,9 +4,7 @@ import com.mealkitary.address.payload.KakaoApiAddressResponse import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient - -private const val KAKAO_API_BASE_URL = "/v2/local/search/address" -private const val FORMAT = "json" +import org.springframework.web.util.DefaultUriBuilderFactory @Component class KakaoApiWebClient( @@ -15,13 +13,13 @@ class KakaoApiWebClient( private val serviceKey: String, ) { - fun requestAddress(query: String): KakaoApiAddressResponse { - val kakaoApiAddressResponse = webClient.get() - .uri { uriBuilder -> - uriBuilder.path("$KAKAO_API_BASE_URL.$FORMAT") - .queryParam("query", query) - .build() - } + fun requestAddress(query: String, baseUrl: String): KakaoApiAddressResponse { + val kakaoApiAddressResponse = webClient + .mutate() + .uriBuilderFactory(DefaultUriBuilderFactory()) + .build() + .get() + .uri("$baseUrl/v2/local/search/address.json?query=$query") .header("Authorization", "KakaoAK $serviceKey") .retrieve() .bodyToMono(KakaoApiAddressResponse::class.java) diff --git a/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/main/kotlin/com/mealkitary/address/payload/KakaoApiAddressResponse.kt b/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/main/kotlin/com/mealkitary/address/payload/KakaoApiAddressResponse.kt index 95f800d..ccd7410 100644 --- a/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/main/kotlin/com/mealkitary/address/payload/KakaoApiAddressResponse.kt +++ b/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/main/kotlin/com/mealkitary/address/payload/KakaoApiAddressResponse.kt @@ -1,23 +1,52 @@ package com.mealkitary.address.payload data class KakaoApiAddressResponse( - val document: Document -) { - data class Document( - val x: String, - val y: String, - val address: Address, - val road_address: RoadAddress - ) + val documents: List, + val meta: Meta +) - data class Address( - val region_1depth_name: String, - val region_2depth_name: String, - val region_3depth_name: String, - ) +data class Meta( + val is_end: Boolean, + val pageable_count: Int, + val total_count: Int +) - data class RoadAddress( - val road_name: String, - val h_code: String - ) -} +data class Document( + val address_name: String, + val address_type: String, + val address: Address?, + val road_address: RoadAddress?, + val x: String, + val y: String +) + +class Address( + val address_name: String, + val b_code: String, + val h_code: String, + val main_address_no: String, + val mountain_yn: String, + val region_1depth_name: String, + val region_2depth_name: String, + val region_3depth_h_name: String, + val region_3depth_name: String, + val sub_address_no: String, + val x: String, + val y: String + +) + +class RoadAddress( + val address_name: String, + val building_name: String, + val main_building_no: String, + val region_1depth_name: String, + val region_2depth_name: String, + val region_3depth_name: String, + val road_name: String, + val sub_building_no: String, + val underground_yn: String, + val x: String, + val y: String, + val zone_no: String +) diff --git a/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/test/kotlin/com/mealkitary/addess/KakaoApiAddressResolverTest.kt b/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/test/kotlin/com/mealkitary/addess/KakaoApiAddressResolverTest.kt index 78ff0b2..4b1313a 100644 --- a/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/test/kotlin/com/mealkitary/addess/KakaoApiAddressResolverTest.kt +++ b/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/test/kotlin/com/mealkitary/addess/KakaoApiAddressResolverTest.kt @@ -2,11 +2,19 @@ package com.mealkitary.addess import com.mealkitary.address.KakaoApiAddressResolver import com.mealkitary.address.KakaoApiWebClient +import com.mealkitary.address.payload.Address +import com.mealkitary.address.payload.Document import com.mealkitary.address.payload.KakaoApiAddressResponse +import com.mealkitary.address.payload.Meta +import com.mealkitary.address.payload.RoadAddress +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.AnnotationSpec +import io.kotest.inspectors.forAll import io.kotest.matchers.shouldBe +import io.kotest.matchers.throwable.shouldHaveMessage import io.mockk.every import io.mockk.mockk +import io.mockk.verify class KakaoApiAddressResolverTest : AnnotationSpec() { @@ -14,34 +22,252 @@ class KakaoApiAddressResolverTest : AnnotationSpec() { private val kakaoApiAddressResolver = KakaoApiAddressResolver(kakaoApiWebClient) @Test - fun `Kakao API를 통해 해당하는 주소 정보를 받아온다`() { - val address = "경기도 남양주시 다산중앙로82번안길 132-12" - val response = KakaoApiAddressResponse( - document = KakaoApiAddressResponse.Document( - x = "127.166069448936", - y = "37.6120947950094", - address = KakaoApiAddressResponse.Address( - region_1depth_name = "경기", - region_2depth_name = "남양주시", - region_3depth_name = "다산동" + fun `카카오 API를 통해 주소 정보를 받아온다`() { + every { kakaoApiWebClient.requestAddress(any(), any()) } answers { + KakaoApiAddressResponse( + listOf( + createDocument( + "ROAD_ADDR", + createRoadAddress( + region1depthName = "경기", + region2depthName = "안양시 만안구", + region3depthName = "안양동", + roadName = "병목안로", + mainBuildingNo = "123", + subBuildingNo = "23" + ), + createAddress(hCode = "4117158100"), x = "126.909658990799", y = "37.3921505309817" + ) ), - road_address = KakaoApiAddressResponse.RoadAddress( - road_name = "다산중앙로82번안길", - h_code = "4136011200" + createMeta(1) + ) + } + + val shopAddress = kakaoApiAddressResolver.resolve("경기도 안양시 만안구 병목안로 123-23") + + shopAddress.address.region1DepthName shouldBe "경기" + shopAddress.address.region2DepthName shouldBe "안양시 만안구" + shopAddress.address.region3DepthName shouldBe "안양동" + shopAddress.address.roadName shouldBe "병목안로 123-23" + shopAddress.coordinates.longitude shouldBe 126.909658990799 + shopAddress.coordinates.latitude shouldBe 37.3921505309817 + shopAddress.cityCode shouldBe "4117158100" + } + + @Test + fun `meta의 검색 결과가 존재하지 않는 경우, 예외를 발생한다`() { + every { kakaoApiWebClient.requestAddress(any(), any()) } answers { + KakaoApiAddressResponse( + listOf(createDocument("LOAD_ADDR", createRoadAddress(), createAddress())), createMeta(0) + ) + } + + shouldThrow { + kakaoApiAddressResolver.resolve("경기도 안양시") + } shouldHaveMessage "올바른 도로명 주소를 입력해주세요." + } + + @Test + fun `document가 하나도 포함되어 있지 않다면, 예외를 발생한다`() { + every { kakaoApiWebClient.requestAddress(any(), any()) } answers { + KakaoApiAddressResponse(emptyList(), createMeta(10)) + } + + shouldThrow { + kakaoApiAddressResolver.resolve("경기도 안양시") + } shouldHaveMessage "올바른 도로명 주소를 입력해주세요." + } + + @Test + fun `검색 결과의 첫 번째 결과의 주소 타입이 ROAD_ADDR이 아닌 경우 예외를 발생한다`() { + listOf("REGION", "ROAD", "REGION_ADDR").forAll { addrType -> + every { kakaoApiWebClient.requestAddress(any(), any()) } answers { + KakaoApiAddressResponse( + listOf( + createDocument(addrType, createRoadAddress(), createAddress()), + createDocument("ROAD_ADDR", createRoadAddress(), createAddress()) + ), + createMeta(1) ) + } + + shouldThrow { + kakaoApiAddressResolver.resolve("경기도 안양시") + } shouldHaveMessage "올바른 도로명 주소를 입력해주세요." + } + } + + @Test + fun `해당하는 도로명 주소(RoadAddress)가 존재하지 않는 경우, 예외를 발생한다`() { + every { kakaoApiWebClient.requestAddress(any(), any()) } answers { + KakaoApiAddressResponse( + listOf( + createDocument("ROAD_ADDR", null, createAddress()) + ), + createMeta(1) ) - ) + } + + shouldThrow { + kakaoApiAddressResolver.resolve("경기도 안양시") + } shouldHaveMessage "올바른 도로명 주소를 입력해주세요." + } - every { kakaoApiWebClient.requestAddress(address) } returns response + @Test + fun `해당하는 주소(Address)가 존재하지 않는 경우, 예외를 발생한다`() { + every { kakaoApiWebClient.requestAddress(any(), any()) } answers { + KakaoApiAddressResponse( + listOf( + createDocument("ROAD_ADDR", createRoadAddress(), null) + ), + createMeta(1) + ) + } - val shopAddress = kakaoApiAddressResolver.resolve(address) + shouldThrow { + kakaoApiAddressResolver.resolve("경기도 안양시") + } shouldHaveMessage "올바른 도로명 주소를 입력해주세요." + } + + @Test + fun `HTTP 클라이언트에게 카카오 API 경로를 전달한다`() { + every { kakaoApiWebClient.requestAddress(any(), any()) } answers { + KakaoApiAddressResponse( + listOf( + createDocument("ROAD_ADDR", createRoadAddress(), createAddress()) + ), + createMeta(1) + ) + } + + kakaoApiAddressResolver.resolve("경기도 안양시 만안구") + + verify { kakaoApiWebClient.requestAddress(any(), "https://dapi.kakao.com") } + } + + @Test + fun `도로명 주소에 서브 빌딩 번호가 존재하지 않는다면 메인 빌딩 번호만 포함해야한다`() { + every { kakaoApiWebClient.requestAddress(any(), any()) } answers { + KakaoApiAddressResponse( + listOf( + createDocument( + "ROAD_ADDR", + createRoadAddress( + region1depthName = "경기", + region2depthName = "안양시 만안구", + region3depthName = "안양동", + roadName = "병목안로", + mainBuildingNo = "123" + ), + createAddress() + ) + ), + createMeta(1) + ) + } + + val shopAddress = kakaoApiAddressResolver.resolve("경기도 안양시 만안구 병목안로 123") + + shopAddress.address.region1DepthName shouldBe "경기" + shopAddress.address.region2DepthName shouldBe "안양시 만안구" + shopAddress.address.region3DepthName shouldBe "안양동" + shopAddress.address.roadName shouldBe "병목안로 123" + } + + @Test + fun `도로명 주소에 서브 빌딩 번호가 존재한다면 하이픈을 구분자로 포함해야한다`() { + every { kakaoApiWebClient.requestAddress(any(), any()) } answers { + KakaoApiAddressResponse( + listOf( + createDocument( + "ROAD_ADDR", + createRoadAddress( + region1depthName = "경기", + region2depthName = "안양시 만안구", + region3depthName = "안양동", + roadName = "병목안로", + mainBuildingNo = "123", + subBuildingNo = "23" + ), + createAddress() + ) + ), + createMeta(1) + ) + } + + val shopAddress = kakaoApiAddressResolver.resolve("경기도 안양시 만안구 병목안로 123-23") shopAddress.address.region1DepthName shouldBe "경기" - shopAddress.address.region2DepthName shouldBe "남양주시" - shopAddress.address.region3DepthName shouldBe "다산동" - shopAddress.address.roadName shouldBe "다산중앙로82번안길" - shopAddress.cityCode shouldBe "4136011200" - shopAddress.coordinates.longitude shouldBe 127.166069448936 - shopAddress.coordinates.latitude shouldBe 37.6120947950094 + shopAddress.address.region2DepthName shouldBe "안양시 만안구" + shopAddress.address.region3DepthName shouldBe "안양동" + shopAddress.address.roadName shouldBe "병목안로 123-23" + } + + @Test + fun `유효하지 않는 좌표값을 전달받으면 예외를 발생한다`() { + every { kakaoApiWebClient.requestAddress(any(), any()) } answers { + KakaoApiAddressResponse( + listOf( + createDocument( + "ROAD_ADDR", + createRoadAddress( + region1depthName = "경기", + region2depthName = "안양시 만안구", + region3depthName = "안양동", + roadName = "병목안로", + mainBuildingNo = "123", + subBuildingNo = "23" + ), + createAddress(), x = "", y = "" + ) + ), + createMeta(1) + ) + } + + shouldThrow { + kakaoApiAddressResolver.resolve("경기도 안양시 만안구 병목안로 129-27") + } shouldHaveMessage "유효하지 않은 좌표 범위입니다." } + + fun createMeta(totalCount: Int) = Meta( + false, 10, totalCount + ) + + fun createDocument( + addrType: String, + roadAddress: RoadAddress?, + address: Address?, + x: String = "123", + y: String = "32" + ) = Document( + "경기도 안양시 만안구 병목안로 123-23", addrType, address, roadAddress, x, y + ) + + fun createAddress(hCode: String = "1234567890") = Address( + "", "", hCode, "", "", "", "", "", "", "", "", "" + ) + + fun createRoadAddress( + region1depthName: String = "", + region2depthName: String = "", + region3depthName: String = "", + roadName: String = "", + mainBuildingNo: String = "", + subBuildingNo: String = "", + ) = RoadAddress( + "", + "", + mainBuildingNo, + region1depthName, + region2depthName, + region3depthName, + roadName, + subBuildingNo, + "", + "", + "", + "" + ) } diff --git a/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/test/kotlin/com/mealkitary/addess/KakaoApiWebClientTest.kt b/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/test/kotlin/com/mealkitary/addess/KakaoApiWebClientTest.kt index 84bd7e7..62edbb8 100644 --- a/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/test/kotlin/com/mealkitary/addess/KakaoApiWebClientTest.kt +++ b/mealkitary-infrastructure/adapter-address-resolver/kakao-api-address-resolver/src/test/kotlin/com/mealkitary/addess/KakaoApiWebClientTest.kt @@ -2,7 +2,11 @@ package com.mealkitary.addess import com.fasterxml.jackson.databind.ObjectMapper import com.mealkitary.address.KakaoApiWebClient +import com.mealkitary.address.payload.Address +import com.mealkitary.address.payload.Document import com.mealkitary.address.payload.KakaoApiAddressResponse +import com.mealkitary.address.payload.Meta +import com.mealkitary.address.payload.RoadAddress import io.kotest.assertions.throwables.shouldThrow import io.kotest.inspectors.forAll import io.kotest.matchers.shouldBe @@ -14,7 +18,6 @@ import org.junit.jupiter.api.Test import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.web.reactive.function.client.WebClient -import java.lang.RuntimeException class KakaoApiWebClientTest { @@ -58,18 +61,22 @@ class KakaoApiWebClientTest { .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) ) - val actualResponse = kakaoApiWebClient.requestAddress(address) + val actualResponse = kakaoApiWebClient.requestAddress(address, mockWebServer.url("").toString()) val recordedRequest = mockWebServer.takeRequest() recordedRequest.method shouldBe "GET" - actualResponse.document.road_address.h_code shouldBe response.document.road_address.h_code - actualResponse.document.x shouldBe response.document.x - actualResponse.document.y shouldBe response.document.y - actualResponse.document.address.region_1depth_name shouldBe response.document.address.region_1depth_name - actualResponse.document.address.region_2depth_name shouldBe response.document.address.region_2depth_name - actualResponse.document.address.region_3depth_name shouldBe response.document.address.region_3depth_name - actualResponse.document.road_address.road_name shouldBe response.document.road_address.road_name + actualResponse.documents[0].address!!.h_code shouldBe response.documents[0].address!!.h_code + actualResponse.documents[0].x shouldBe response.documents[0].x + actualResponse.documents[0].y shouldBe response.documents[0].y + actualResponse.documents[0].address!!.region_1depth_name shouldBe + response.documents[0].address!!.region_1depth_name + actualResponse.documents[0].address!!.region_2depth_name shouldBe + response.documents[0].address!!.region_2depth_name + actualResponse.documents[0].address!!.region_3depth_name shouldBe + response.documents[0].address!!.region_3depth_name + actualResponse.documents[0].road_address!!.road_name shouldBe + response.documents[0].road_address!!.road_name } @Test @@ -81,25 +88,50 @@ class KakaoApiWebClientTest { .setBody(objectMapper.writeValueAsString(createResponse())) .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) ) + shouldThrow { - kakaoApiWebClient.requestAddress("경기도남양주시다산중앙로82번안길132-12") + kakaoApiWebClient.requestAddress("경기도남양주시다산중앙로82번안길132-12", mockWebServer.url("").toString()) } } } private fun createResponse() = KakaoApiAddressResponse( - document = KakaoApiAddressResponse.Document( - x = "127.166069448936", - y = "37.6120947950094", - address = KakaoApiAddressResponse.Address( - region_1depth_name = "경기", - region_2depth_name = "남양주시", - region_3depth_name = "다산동" - ), - road_address = KakaoApiAddressResponse.RoadAddress( - road_name = "다산중앙로82번안길", - h_code = "4136011200" + documents = listOf( + Document( + x = "127.166069448936", + y = "37.6120947950094", + address = Address( + region_1depth_name = "경기", + region_2depth_name = "남양주시", + region_3depth_name = "다산동", + region_3depth_h_name = "", + h_code = "4136011200", + address_name = "", + main_address_no = "", + sub_address_no = "", + mountain_yn = "", + b_code = "", + x = "", + y = "" + ), + road_address = RoadAddress( + road_name = "다산중앙로82번안길", + address_name = "", + x = "", + y = "", + building_name = "", + main_building_no = "", + sub_building_no = "", + region_1depth_name = "", + region_2depth_name = "", + region_3depth_name = "", + underground_yn = "", + zone_no = "" + ), + address_name = "", + address_type = "" ) - ) + ), + meta = Meta(false, 1, 1) ) }