diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt index a9cc3ef..678af78 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt @@ -1,4 +1,83 @@ package com.back.koreaTravelGuide.domain.ai.tour.client -// TODO: 관광청 API 클라이언트 - 관광 정보 API 호출 및 응답 처리 -class TourApiClient +import com.back.koreaTravelGuide.domain.ai.tour.dto.InternalData +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate +import org.springframework.web.util.UriComponentsBuilder +import java.net.URI + +// 09.25 양현준 +@Component +class TourApiClient( + private val restTemplate: RestTemplate, + private val objectMapper: ObjectMapper, + @Value("\${tour.api.key}") private val serviceKey: String, + @Value("\${tour.api.base-url}") private val apiUrl: String, +) { + // 요청 URL 구성 + private fun buildUrl(params: InternalData): URI = + UriComponentsBuilder.fromUri(URI.create(apiUrl)) + .path("/areaBasedList2") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "WEB") + .queryParam("MobileApp", "KoreaTravelGuide") + .queryParam("_type", "json") + .queryParam("numOfRows", params.numOfRows) + .queryParam("pageNo", params.pageNo) + .queryParam("contentTypeId", params.contentTypeId) + .queryParam("areaCode", params.areaCode) + .queryParam("sigunguCode", params.sigunguCode) + .build() + .encode() + .toUri() + + // 지역 기반 관광 정보 조회 (areaBasedList2) + fun fetchTourInfo(params: InternalData): TourResponse? { + println("URL 생성") + val url = buildUrl(params) + + println("관광 정보 조회 API 호출: $url") + + return try { + val jsonResponse = restTemplate.getForObject(url, String::class.java) + println("관광 정보 응답 길이: ${jsonResponse?.length ?: 0}") + + if (jsonResponse.isNullOrBlank()) return null // HTTP 호출 결과가 null이거나 공백 문자열일 때 + + val root = objectMapper.readTree(jsonResponse) // 문자열을 Jackson 트리 구조(JsonNode)로 변환 + val itemsNode = + root // path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감 + .path("response") + .path("body") + .path("items") + .path("item") + + if (!itemsNode.isArray || itemsNode.isEmpty) return null // 탐색 결과가 비어 있는 경우 + + val firstItem = itemsNode.first() + TourResponse( + contentId = firstItem.path("contentid").asText(), + contentTypeId = firstItem.path("contenttypeid").asText(), + createdTime = firstItem.path("createdtime").asText(), + modifiedTime = firstItem.path("modifiedtime").asText(), + title = firstItem.path("title").asText(), + addr1 = firstItem.path("addr1").takeIf { it.isTextual }?.asText(), + areaCode = firstItem.path("areacode").takeIf { it.isTextual }?.asText(), + firstimage = firstItem.path("firstimage").takeIf { it.isTextual }?.asText(), + firstimage2 = firstItem.path("firstimage2").takeIf { it.isTextual }?.asText(), + mapX = firstItem.path("mapx").takeIf { it.isTextual }?.asText(), + mapY = firstItem.path("mapy").takeIf { it.isTextual }?.asText(), + mlevel = firstItem.path("mlevel").takeIf { it.isTextual }?.asText(), + sigunguCode = firstItem.path("sigungucode").takeIf { it.isTextual }?.asText(), + lDongRegnCd = firstItem.path("lDongRegnCd").takeIf { it.isTextual }?.asText(), + lDongSignguCd = firstItem.path("lDongSignguCd").takeIf { it.isTextual }?.asText(), + ) + } catch (e: Exception) { + println("관광 정보 조회 오류: ${e.message}") + null + } + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/InternalData.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/InternalData.kt new file mode 100644 index 0000000..87a8a03 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/InternalData.kt @@ -0,0 +1,20 @@ +package com.back.koreaTravelGuide.domain.ai.tour.dto + +/** + * 9.25 양현준 + * 관광 정보 호출용 파라미터 + * 기능상, 생략 가능한 필드는 생략 (arrange : 제목 순으로 정렬, cat : 대,중,소 분류, crpyrhtDivCd: 저작권유형) + */ + +data class InternalData( + // 한 페이지 데이터 수, 10으로 지정 + val numOfRows: Int = 10, + // 페이지 번호, 1로 지정 + val pageNo: Int = 1, + // 관광타입 ID, 미 입력시 전체 조회 (12:관광지, 38 : 쇼핑...), + val contentTypeId: String? = "", + // 지역코드, 미 입력시 지역 전체 조회 (1:서울, 2:인천...) + val areaCode: String? = "", + // 시군구코드, 미 입력시 전체 조회 + val sigunguCode: String? = "", +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt index 1874ef2..63a0a16 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt @@ -1,8 +1,39 @@ package com.back.koreaTravelGuide.domain.ai.tour.dto -// TODO: 관광 정보 응답 DTO - 관광지 정보 및 메타데이터 반환 +/** + * 9.25 양현준 + * 관광 정보 응답 DTO + * API 매뉴얼에서 필수인 값은 NonNull로 지정. + */ data class TourResponse( - val name: String, - val location: String, - val description: String, + // 콘텐츠ID (고유 번호) + val contentId: String, + // 관광타입 ID (12: 관광지, 14: 문화시설 ..) + val contentTypeId: String, + // 등록일 + val createdTime: String, + // 수정일 + val modifiedTime: String, + // 제목 + val title: String, + // 주소 + val addr1: String?, + // 지역코드 + val areaCode: String?, + // 이미지 (URL) + val firstimage: String?, + // 썸네일 이미지 (URL) + val firstimage2: String?, + // 경도 + val mapX: String?, + // 위도 + val mapY: String?, + // 지도 레벨 + val mlevel: String?, + // 시군구코드 + val sigunguCode: String?, + // 법정동 시도 코드, 응답 코드가 IDongRegnCd 이므로, + val lDongRegnCd: String?, + // 법정동 시군구 코드 + val lDongSignguCd: String?, ) diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt new file mode 100644 index 0000000..d742685 --- /dev/null +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt @@ -0,0 +1,159 @@ +package com.back.koreaTravelGuide.domain.ai.tour.client + +import com.back.koreaTravelGuide.application.KoreaTravelGuideApplication +import com.back.koreaTravelGuide.domain.ai.tour.dto.InternalData +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.context.annotation.Bean +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.client.MockRestServiceServer +import org.springframework.test.web.client.match.MockRestRequestMatchers.method +import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess +import org.springframework.web.client.RestTemplate +import org.springframework.web.util.UriComponentsBuilder +import java.net.URI +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +// 09.25 양현준 +@ExtendWith(SpringExtension::class) +// 패키지 경로에서 메인 설정을 찾지 못하는 오류를 해결하기 위해 애플리케이션 클래스를 명시. +@SpringBootTest(classes = [KoreaTravelGuideApplication::class]) +@ActiveProfiles("test") +class TourApiClientTest { + @Autowired private lateinit var restTemplateBuilder: RestTemplateBuilder + + @Autowired private lateinit var objectMapper: ObjectMapper + + @Value("\${tour.api.key}") + private lateinit var serviceKey: String + + @Value("\${tour.api.base-url}") + private lateinit var apiUrl: String + + private lateinit var restTemplate: RestTemplate + private lateinit var mockServer: MockRestServiceServer + private lateinit var tourApiClient: TourApiClient + + // 테스트마다 클라이언트와 Mock 서버를 새로 구성해 호출 상태를 초기화. + @BeforeEach + fun setUp() { + restTemplate = restTemplateBuilder.build() + mockServer = MockRestServiceServer.createServer(restTemplate) + tourApiClient = TourApiClient(restTemplate, objectMapper, serviceKey, apiUrl) + } + + // 첫 번째 관광 정보를 반환하는지. + @DisplayName("TourApiClient - fetchTourInfo") + @Test + fun testReturnsFirstTourInfo() { + val params = InternalData(numOfRows = 2, pageNo = 1, areaCode = "1", sigunguCode = "7") + expectTourRequest(params, responseWithItems(sampleTourItem())) + + val result: TourResponse? = tourApiClient.fetchTourInfo(params) + + mockServer.verify() + assertNotNull(result) + assertEquals("2591792", result.contentId) + assertEquals("개봉유수지 생태공원", result.title) + assertEquals("7", result.sigunguCode) + } + + // item 배열이 비어 있으면 null을 돌려주는지. + @DisplayName("TourApiClient - fetchTourInfo") + @Test + fun testReturnsNullWhenItemsMissing() { + val params = InternalData(numOfRows = 1, pageNo = 1, areaCode = "1", sigunguCode = "7") + expectTourRequest(params, responseWithItems()) + + val result = tourApiClient.fetchTourInfo(params) + + mockServer.verify() + assertNull(result) + } + + // 요청 URL과 응답 바디를 미리 세팅해 Mock 서버가 기대한 호출을 검증. + private fun expectTourRequest( + params: InternalData, + responseBody: String, + ) { + mockServer.expect(requestTo(buildExpectedUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)) + } + + // 실제 클라이언트가 조합하는 URL과 동일한 형태를 만들어 비교한다. + private fun buildExpectedUrl(params: InternalData): String = + UriComponentsBuilder.fromUri(URI.create(apiUrl)) + .path("/areaBasedList2") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "WEB") + .queryParam("MobileApp", "KoreaTravelGuide") + .queryParam("_type", "json") + .queryParam("numOfRows", params.numOfRows) + .queryParam("pageNo", params.pageNo) + .queryParam("contentTypeId", params.contentTypeId) + .queryParam("areaCode", params.areaCode) + .queryParam("sigunguCode", params.sigunguCode) + .build() + .encode() + .toUriString() + + // Jackson을 활용해 테스트 응답 JSON을 손쉽게 조립한다. + private fun responseWithItems(vararg items: Map): String { + val response = + mapOf( + "response" to + mapOf( + "header" to mapOf("resultCode" to "0000", "resultMsg" to "OK"), + "body" to + mapOf( + "items" to mapOf("item" to items.toList()), + ), + ), + ) + + return objectMapper.writeValueAsString(response) + } + + // 테스트용 샘플 관광지 정의한다. + private fun sampleTourItem(): Map = + mapOf( + "contentid" to "2591792", + "contenttypeid" to "12", + "createdtime" to "20190313221125", + "modifiedtime" to "20250316162225", + "title" to "개봉유수지 생태공원", + "addr1" to "서울특별시 구로구 개봉동", + "areacode" to "1", + "firstimage" to "", + "firstimage2" to "", + "mapx" to "126.8632141714", + "mapy" to "37.4924524597", + "mlevel" to "6", + "sigungucode" to "7", + "lDongRegnCd" to "11", + "lDongSignguCd" to "530", + ) + + // 테스트에서 RestTemplateBuilder 빈을 보장해 컨텍스트 로딩 실패 해결. + @TestConfiguration + class TestConfig { + @Bean + fun restTemplateBuilder(): RestTemplateBuilder = RestTemplateBuilder() + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 483426f..8154d0f 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -6,8 +6,8 @@ weather.api.key=8298f7760d58f7d4a0104d016d5792a33b0048953afda89d5c0bb3fef251141c weather.api.base-url=https://apis.data.go.kr/1360000/MidFcstInfoService # Tour API Configuration -tour.api.key=${TOUR_API_KEY:test-key} -tour.api.base-url=${TOUR_API_BASE_URL:https://apis.data.go.kr/B551011/KorService1} +tour.api.key=${TOUR_API_KEY:Pp8aOoKZql09DdDfb4r9SsFWIepIqaocvCQzJphWcvmBj0ff9KuvikfKjgxrXqK03JNrmOIjOZyLyZhjlY43AQ==} +tour.api.base-url=${TOUR_API_BASE_URL:https://apis.data.go.kr/B551011/KorService2} # Spring AI Configuration spring.ai.openai.api-key=${OPENAI_API_KEY:test-key}