영화앱을 구현하면서 영화진흥위원회
에서 제공해주는 Open API는 영화 포스터 이미지를 받아올 수 없었고...
하여 카카오
SDK의 검색 API와 TMDB
의 Open API를 사용하는 방법을 고안했다.
총 3가지의 API를 받아오기 위해 오픈소스 라이브러리 사용하기보다
직접 구현을 통해 해결해보고자 했다❗️
처음으로 공통된 부분을 찾아야했고 크게 요청과 응답
으로 나누었고
아래와 같은 API 요청 항목을 확인할 수 있었다.
또한 API로 부터 받아온 Response 값을 하나로 통일
해야한다는 점이 있었다.
그렇게 Endpoint에 필요한 사항들을 프로토콜로 정의하였다.
protocol Requestable {
var baseURL: String { get }
var firstPath: String { get }
var secondPath: String? { get }
var method: HTTPMethodType { get }
var queryParameters: Encodable? { get }
var headers: [String: String]? { get }
}
protocol Responsable {
associatedtype Responese
}
위 2가지의 프로토콜을 합성하여 Endpoint를 구현하였을 때 아래와 같았다.
protocol RequestAndResponsable: Requestable, Responsable { }
final class EndPoint<R>: RequestAndResponsable {
typealias Responese = R
var baseURL: String
var firstPath: String
var secondPath: String?
var method: HTTPMethodType
var queryParameters: Encodable?
var headers: [String : String]?
init (
baseURL: String,
firstPath: String,
secondPath: String? = nil,
method: HTTPMethodType = .get,
queryParameters: Encodable? = nil,
headers: [String : String]? = nil
) {
self.baseURL = baseURL
self.firstPath = firstPath
self.secondPath = secondPath
self.method = method
self.queryParameters = queryParameters
self.headers = headers
}
}
enum HTTPMethodType: String {
case get = "GET"
}
여기까지는 제네릭과 프로토콜의 associatedType을 통한 추상화된 객체를 만들었을 뿐..
Endpoint를 통해 API 요청과 응답을 받을 수 있는 메서드가 없기에
Requestable
프로토콜에 추가적으로 URL을 요청할 수 있으며
decoding의 역할을 같이 할 수 있도록 아래와 같이 구현하였다.
extension Requestable {
func receiveURLRequest<E: RequestAndResponsable>(by endPoint: E) throws -> URLRequest {
guard let fullPath = try decide(toType: endPoint) else {
throw URLComponentsError.invalidComponent
}
let url = try makeURL(by: fullPath)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = method.rawValue
headers?.forEach({ (key: String, value: String) in
urlRequest.setValue(value, forHTTPHeaderField: key)
})
return urlRequest
}
private func makeURL(by fullPath: String) throws -> URL {
var urlComponents = try verify(by: fullPath)
var urlQueryItems = [URLQueryItem]()
if let queryParameters = try queryParameters?.toDictionary() {
let sortedQueryParameters = queryParameters.sorted { (first, second) in
return first.key < second.key
}
sortedQueryParameters.forEach {
urlQueryItems.append(URLQueryItem(name: $0.key, value: "\($0.value)"))
}
}
urlComponents.queryItems = urlQueryItems
guard let url = urlComponents.url else {
throw URLComponentsError.invalidComponent
}
return url
}
private func verify(by fullPath: String) throws -> URLComponents {
guard let urlComponents = URLComponents(string: fullPath) else {
throw URLComponentsError.invalidComponent
}
return urlComponents
}
private func decide<E: RequestAndResponsable>(toType endPoint: E) throws -> String? {
if endPoint is EndPoint<TMDBTrendMovieList> {
return "\(baseURL)\(firstPath)"
}
if endPoint is EndPoint<BoxOffice> ||
endPoint is EndPoint<MovieDetailInformation> {
return "\(baseURL)\(firstPath)\(secondPath ?? "")\(KOFICBasic.format)"
}
if endPoint is EndPoint<MoviePosterImage> {
return "\(baseURL)\(firstPath)"
}
...
return nil
}
}
extension Encodable {
func toDictionary() throws -> [String: Any]? {
let data = try JSONEncoder().encode(self)
let jsonData = try JSONSerialization.jsonObject(with: data)
return jsonData as? [String: Any]
}
}
위 Endpoint를 통해 API를 요청하는 방법이 하나로 통일되었음을 확인할 수 있었고
구현은 다음과 같다.
struct TMDBAPIEndPoint {
static func receiveWeakTrendingList(with weakMovieListRequestDTO: TMDBQueryParameters) -> EndPoint<TMDBTrendMovieList> {
return EndPoint(baseURL: TMDBBasic.trendMovieListBaseURL,
firstPath: TMDBBasic.pathQueryOfWeak,
queryParameters: weakMovieListRequestDTO
)
}
}
struct KakaoEndPoint {
static func receiveMoviePosterImage(with kakaoImageRequestDTO: KoreaMovieListImageQueryParameters) -> EndPoint<MoviePosterImage> {
return EndPoint(baseURL: KakaoBasic.baseURL,
firstPath: KakaoBasic.image,
queryParameters: kakaoImageRequestDTO,
headers: KakaoBasic.headers
)
}
}
struct KOFICAPIEndPoint {
static func receiveBoxOffice(with boxOfficeRequestDTO: BoxOfficeQueryParameters) -> EndPoint<BoxOffice> {
return EndPoint(baseURL: KOFICBasic.baseURL,
firstPath: Show.boxOffice,
secondPath: Show.searchDailyList,
queryParameters: boxOfficeRequestDTO)
}
}
위 메서드들은 API 요청에 필요한 Query 값들을 넘겨주어 Endpoint 타입을 받아와
네트워크 서비스를 통해 정말 디코딩되어 나온 결과값을 받기위한 준비과정이었다.
하여 아래와 같이 NetworkService
라는 객체를 통해 API를 요청할 수 있도록 했다.
final class NetworkService {
//MARK: - Initializer
init() {
self.session = URLSession(configuration: .default)
}
//MARK: - Private Property
private let session: URLSession
}
//MARK: - [Private Method] Configure of Service (URLResponse, Decoding)
extension NetworkService {
func request<R: Decodable, E: RequestAndResponsable>(with endPoint: E) async throws -> R where E.Responese == R {
let urlRequest = try endPoint.receiveURLRequest(by: endPoint)
let (data, response) = try await session.data(for: urlRequest)
if let identifiedResponse = response as? HTTPURLResponse, !(200...299).contains(identifiedResponse.statusCode) {
try self.verify(with: identifiedResponse)
}
let decodedData: R = try self.decode(with: data)
return decodedData
}
private func verify(with HTTPResponse: HTTPURLResponse) throws {
switch HTTPResponse.statusCode {
case (300...399):
throw HTTPErrorType.redirectionMessages(
HTTPResponse.statusCode, HTTPResponse.debugDescription
)
case (400...499):
throw HTTPErrorType.clientErrorResponses(
HTTPResponse.statusCode, HTTPResponse.debugDescription
)
case (500...599):
throw HTTPErrorType.serverErrorResponses(
HTTPResponse.statusCode, HTTPResponse.debugDescription
)
default:
throw HTTPErrorType.networkFailError(
HTTPResponse.statusCode
)
}
}
private func decode<T: Decodable>(with apiData: Data) throws -> T {
var decode: T
let decoder = JSONDecoder()
do {
decode = try decoder.decode(T.self, from: apiData) as T
} catch {
throw NetworkError.decodeError
}
return decode
}
}
위 NetworkService 추상화된 객체를 가지고 있는 Loader라는 개념을 두어 홈화면과 디테일 화면
따로 관리할 수 있도록 하였고 아래는HomeLoader
를 통해 관리할 수 있도록하였다.
final class HomeLoader {
//MARK: - Initializer
init() {
self.networkService = NetworkService()
}
//MARK: - Private Property
private let networkService: NetworkService
}
//MARK: - [Public Method] Use of async & await
extension HomeLoader {
/// TrendMovieList
func loadTrendMovieList() async throws -> [Result] {
let popularMovieListQueryParameters = TMDBQueryParameters()
guard let networkResult = try? await networkService.request(
with: TMDBAPIEndPoint.receiveWeakTrendingList(
with: popularMovieListQueryParameters)
).results else {
throw DataLoadError.failOfTrendMovieListData
}
return networkResult
}
/// StillCut
func loadStillCut(movieNameGroup: [String]) async throws -> [Document] {
let moviePosterImageParametersGroup = movieNameGroup.map { movieName in
KoreaMovieListImageQueryParameters(query: movieName)
}
var networkResult = [Document]()
for moviePosterImageParameters in moviePosterImageParametersGroup {
do {
var result = try await networkService.request(
with: KakaoEndPoint.receiveMoviePosterImage(
with: moviePosterImageParameters)).documents
let bestResult = result.removeFirst()
networkResult.append(bestResult)
} catch {
throw DataLoadError.failOfStillCutData
}
}
return networkResult
}
/// KoreaBoxOfficeMovieList
func loadDailyBoxOfficeMovieListData() async -> [DailyBoxOfficeList] {
let yesterdayDate = Getter.receiveCurrentDate.split(separator: "-").joined()
let boxOfficeQueryParameters = BoxOfficeQueryParameters(targetDate: yesterdayDate)
var networkResult = [DailyBoxOfficeList]()
do {
networkResult = try await networkService.request(
with: KOFICAPIEndPoint.receiveBoxOffice(
with: boxOfficeQueryParameters)
).boxOfficeResult.dailyBoxOfficeList
} catch {
print(DataLoadError.failOfkoreaBoxOfficeMovieListData)
}
return networkResult
}
}
[iOS - swift] 네트워크(network) - testable한 URLSession 설계 (Endpoint, Provider)