Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
// 페이지 번호, 미 입력시 10
val pageNo: Int = 1,
// 관광타입 ID, 미 입력시 전체 조회 (12:관광지, 38 : 쇼핑...), 우선 관광지로 하드코딩
val contentTypeId: String = "12",
// 지역코드, 미 입력시 지역 전체 (1:서울, 2:인천...)
val areaCode: String,
// 시군구코드, 미 입력시 전체
val sigunguCode: String,
)
Original file line number Diff line number Diff line change
@@ -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?,
)
Original file line number Diff line number Diff line change
@@ -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, String>): 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<String, String> =
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()
}
}
4 changes: 2 additions & 2 deletions src/test/resources/application-test.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down