N개의 API를 사용하기 위한 Endpoint 설계

Jason·2023년 11월 22일
0

영화앱을 구현하면서 영화진흥위원회에서 제공해주는 Open API는 영화 포스터 이미지를 받아올 수 없었고...
하여 카카오 SDK의 검색 API와 TMDB의 Open API를 사용하는 방법을 고안했다.

총 3가지의 API를 받아오기 위해 오픈소스 라이브러리 사용하기보다
직접 구현을 통해 해결해보고자 했다❗️

처음으로 공통된 부분을 찾아야했고 크게 요청과 응답으로 나누었고
아래와 같은 API 요청 항목을 확인할 수 있었다.

  • URL 주소
  • URL Path
  • URL Components
  • HTTP Method
  • Header
  • Query Parameters
  • APIKEY

또한 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
    }
}

🌐 Reference Site

[iOS - swift] 네트워크(network) - testable한 URLSession 설계 (Endpoint, Provider)

profile
🧑🏼‍💻 iOS developer

0개의 댓글

관련 채용 정보