-
Notifications
You must be signed in to change notification settings - Fork 2
feat(be) : Tour 엔티티 및 Client 기초 설계 #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
83 changes: 81 additions & 2 deletions
83
src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } | ||
| } | ||
20 changes: 20 additions & 0 deletions
20
src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/InternalData.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| // 페이지 번호, 1로 지정 | ||
| val pageNo: Int = 1, | ||
| // 관광타입 ID, 미 입력시 전체 조회 (12:관광지, 38 : 쇼핑...), | ||
| val contentTypeId: String? = "", | ||
| // 지역코드, 미 입력시 지역 전체 조회 (1:서울, 2:인천...) | ||
| val areaCode: String? = "", | ||
| // 시군구코드, 미 입력시 전체 조회 | ||
| val sigunguCode: String? = "", | ||
| ) |
39 changes: 35 additions & 4 deletions
39
src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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?, | ||
| ) |
159 changes: 159 additions & 0 deletions
159
src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
|
|
||
| // 첫 번째 관광 정보를 반환하는지. | ||
YangHJ2415 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| @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() | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.