Skip to content
Merged
8 changes: 8 additions & 0 deletions SUSA24-iOS/SUSA24-iOS/Resources/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ enum Config {
enum Plist {
static let naverMapClientID = "NAVER_CLOUD_MAP_API_CLIENT_ID"
static let naverMapClientSecret = "NAVER_CLOUD_MAP_API_CLIENT_SECRET"
static let kakaoRestAPIKey = "KAKAO_REST_API_KEY"
}
}

Expand All @@ -37,4 +38,11 @@ extension Config {
}
return key
}()

static let kakaoRestAPIKey: String = {
guard let key = Config.infoDictionary[Keys.Plist.kakaoRestAPIKey] as? String else {
fatalError("❌KAKAO_REST_API_KEY is not set in plist for this configuration❌")
}
return key
}()
}
2 changes: 2 additions & 0 deletions SUSA24-iOS/SUSA24-iOS/Resources/Supporting Files/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KAKAO_REST_API_KEY</key>
<string>$(KAKAO_REST_API_KEY)</string>
<key>NAVER_CLOUD_MAP_API_CLIENT_ID</key>
<string>$(NAVER_CLOUD_MAP_API_CLIENT_ID)</string>
<key>NAVER_CLOUD_MAP_API_CLIENT_SECRET</key>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ enum NetworkConstant {
static let clientID = "X-NCP-APIGW-API-KEY-ID"
static let clientSecret = "X-NCP-APIGW-API-KEY"
}

enum KakaoAPIHeaderKey {
static let authorization = "Authorization"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// NetworkHeader.swift
// SUSA24-iOS
//
// Created by Moo on 11/5/25.
//

import Alamofire
import Foundation

enum NetworkHeader {
/// 카카오 API 공통 헤더
static var kakaoHeaders: HTTPHeaders {
[
NetworkConstant.KakaoAPIHeaderKey.authorization: "KakaoAK \(Config.kakaoRestAPIKey)"
]
}

/// 네이버 API 공통 헤더
static var naverHeaders: HTTPHeaders {
[
NetworkConstant.NaverAPIHeaderKey.clientID: Config.naverMapClientID,
NetworkConstant.NaverAPIHeaderKey.clientSecret: Config.naverMapClientSecret,
]
}
}

91 changes: 91 additions & 0 deletions SUSA24-iOS/SUSA24-iOS/Sources/Util/Network/Base/URLBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// URLBuilder.swift
// SUSA24-iOS
//
// Created by Moo on 11/5/25.
//

import Foundation

/// URL 쿼리 파라미터 조합을 위한 유틸리티
enum URLBuilder {
/// Base URL과 쿼리 파라미터를 조합하여 완전한 URL 문자열을 생성합니다.
/// - Parameters:
/// - baseURL: 기본 URL 문자열
/// - parameters: 쿼리 파라미터 딕셔너리 (nil 값은 자동으로 제외됨)
/// - Returns: 쿼리 파라미터가 포함된 완전한 URL 문자열
/// - Throws: `URLError` (잘못된 URL인 경우)
static func build(
baseURL: String,
parameters: [String: String?]
) throws -> String {
guard let url = URL(string: baseURL) else {
throw URLError(.badURL)
}

var components = URLComponents(url: url, resolvingAgainstBaseURL: false)

// nil이 아닌 값만 필터링하여 쿼리 아이템 생성
let queryItems = parameters
.compactMapValues { $0 } // nil 값 제거
.map { URLQueryItem(name: $0.key, value: $0.value) }

components?.queryItems = queryItems.isEmpty ? nil : queryItems

guard let finalURL = components?.url else {
throw URLError(.badURL)
}

return finalURL.absoluteString
}

/// Base URL과 쿼리 파라미터를 조합하여 완전한 URL 문자열을 생성합니다. (에러 없이 옵셔널 반환)
/// - Parameters:
/// - baseURL: 기본 URL 문자열
/// - parameters: 쿼리 파라미터 딕셔너리 (nil 값은 자동으로 제외됨)
/// - Returns: 쿼리 파라미터가 포함된 완전한 URL 문자열, 실패 시 nil
static func buildOptional(
baseURL: String,
parameters: [String: String?]
) -> String? {
try? build(baseURL: baseURL, parameters: parameters)
}
}

// MARK: - Convenience Extensions

extension URLBuilder {
/// Int 타입 파라미터를 지원하는 오버로드
static func build(
baseURL: String,
parameters: [String: Any?]
) throws -> String {
// Any? 타입을 String?로 변환
let stringParameters = parameters.mapValues { value -> String? in
guard let value = value else { return nil }

if let string = value as? String {
return string
} else if let int = value as? Int {
return String(int)
} else if let double = value as? Double {
return String(double)
} else if let bool = value as? Bool {
return String(bool)
} else {
return String(describing: value)
}
}

return try build(baseURL: baseURL, parameters: stringParameters)
}

/// Int 타입 파라미터를 지원하는 오버로드 (옵셔널 반환)
static func buildOptional(
baseURL: String,
parameters: [String: Any?]
) -> String? {
try? build(baseURL: baseURL, parameters: parameters)
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@

enum URLConstant {
static let geocodeURL = "https://maps.apigw.ntruss.com/map-geocode/v2/geocode"
static let kakaoCoordToLocationURL = "https://dapi.kakao.com/v2/local/geo/coord2address.json"
static let kakaoKeywordToPlaceURL = "https://dapi.kakao.com/v2/local/search/keyword.json"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//
// KakaoCoordToLocationResponseDTO.swift
// SUSA24-iOS
//
// Created by Moo on 11/5/25.
//

import Foundation

struct KakaoCoordToLocationResponseDTO: Decodable, Sendable {
let meta: KakaoMeta
let documents: [KakaoDocument]
}

struct KakaoMeta: Decodable, Sendable {
let totalCount: Int
}

struct KakaoDocument: Decodable, Sendable {
let address: KakaoAddress?
let roadAddress: KakaoRoadAddress?
}

struct KakaoAddress: Decodable, Sendable {
/// 전체 지번 주소
let addressName: String

/// 지역 1 depth, 시도 단위(예: 서울특별시)
let region1depthName: String?

/// 지역 2 depth, 시군구 단위(예: 강남구)
let region2depthName: String?

/// 지역 3 depth, 동 단위(예: 삼성동)
let region3depthName: String?

/// 지역 4 depth, region_type이 H인 경우에만 존재 (예: 상세주소)
let region4depthName: String?

/// 지역 타입
let regionType: String?

/// 행정 코드
let code: String?

/// X 좌표 혹은 경도(longitude)
let x: String?

/// Y 좌표 혹은 위도(latitude)
let y: String?

/// 산 여부 (Y/N)
let mountainYn: String?

/// 지번 본번
let mainAddressNo: String?

/// 지번 부번
let subAddressNo: String?

/// 우편번호
let zipCode: String?
}

struct KakaoRoadAddress: Decodable, Sendable {
/// 전체 도로명 주소
let addressName: String

/// 지역 1 depth, 시도 단위(예: 서울특별시)
let region1depthName: String?

/// 지역 2 depth, 시군구 단위(예: 강남구)
let region2depthName: String?

/// 지역 3 depth, 동 단위(예: 삼성동)
let region3depthName: String?

/// 지역 4 depth, region_type이 H인 경우에만 존재 (예: 상세주소)
let region4depthName: String?

/// 지역 타입
let regionType: String?

/// 행정 코드
let code: String?

let x: String?
let y: String?

/// 건물명
let buildingName: String?

/// 건물 상세 주소
let buildingCode: String?

/// 도로명
let roadName: String?

/// 지하 여부 (Y/N)
let undergroundYn: String?

/// 건물 본번
let mainBuildingNo: String?

/// 건물 부번
let subBuildingNo: String?

/// 우편번호
let zoneNo: String?
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// KakaoKeywordToPlaceResponseDTO.swift
// SUSA24-iOS
//
// Created by Moo on 11/5/25.
//

import Foundation

struct KakaoKeywordToPlaceResponseDTO: Decodable, Sendable {
let meta: KakaoKeywordMeta
let documents: [KakaoPlaceDocument]
}

struct KakaoKeywordMeta: Decodable, Sendable {
/// 검색어에 검색된 문서 수
let totalCount: Int
/// total_count 중 노출 가능 문서 수
let pageableCount: Int
/// 현재 페이지가 마지막 페이지인지 여부
let isEnd: Bool
}

struct KakaoPlaceDocument: Decodable, Sendable {
/// 장소명
let placeName: String?
/// 카테고리 이름
let categoryName: String?
/// 카테고리 그룹 코드
let categoryGroupCode: String?
/// 카테고리 그룹명
let categoryGroupName: String?
/// 전화번호
let phone: String?
/// 전체 지번 주소
let addressName: String?
/// 전체 도로명 주소
let roadAddressName: String?
/// X 좌표값 혹은 longitude
let x: String?
/// Y 좌표값 혹은 latitude
let y: String?
/// 장소 ID
let id: String?
/// 장소 상세페이지 URL
let placeUrl: String?
/// 거리(단위: 미터)
let distance: String?
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// KakaoSearchError.swift
// SUSA24-iOS
//
// Created by Moo on 11/5/25.
//

import Alamofire
import Foundation

/// 카카오 검색 API 에러 타입
enum KakaoSearchError: LocalizedError, Sendable {
case invalidURL
case noResults
case decodingFailed(DecodingError)
case requestFailed(AFError)
case unknown(Error)

var errorDescription: String? {
switch self {
case .invalidURL:
"Invalid URL"
case .noResults:
"No address found for the given coordinates"
case let .decodingFailed(error):
"Failed to decode response: \(error.localizedDescription)"
case let .requestFailed(error):
"Request failed: \(error.localizedDescription)"
case let .unknown(error):
"Unknown error: \(error.localizedDescription)"
}
}
}

Loading