관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] Moya를 사용한 network 구조 설계 (무료 API 테스트, REST) 본문

iOS 응용 (swift)

[iOS - swift] Moya를 사용한 network 구조 설계 (무료 API 테스트, REST)

jake-kim 2021. 8. 16. 22:08

API 테스트 사이트 참고

  • 위 링크 클릭, 복사: https://reqres.in/api/users?page=2

  • 데이터 형식이 page, per_page, total, total_pages, data 형식인 경우 대응 - 두 데이터 사용 예정

Moya 프레임워크

Network 설계 주요 4가지 파일

  • NetworkLoggerPlugin: 네트워크 통신 시 MoyaProvider라는 객체를 통해 접근하는데, MoyaProvider의 파라미터 값으로 plugin객체를 넣어주면 해당 plugin기능을 사용 가능
    • authPlugin: bearer 토큰 세팅 전용의 플러그인
    • LoggerPlugin: response, request 로그를 확인할 수 있는 플러그인
  • Networkable: MoyaProvider 객체를 만들어서 리턴 (Target 타입, plugin 객체를 주입하여 생성)
  • NetworkError: Error프로토콜을 준수하고 있는 에러 정의용도 (response에서 사용)
  • ResponseData: 공통 response에 관하여 Codable로 정의하고 있고, success, failure에 관한 처리를 담당
  • BaseTargetType: Moya에서 제공하는 endpoint에 관해 enum으로 정의하여 편리하게 api호출을 할수 있게 하는 targetType의 base

Moya의 PluginType프로토콜을 conform

  • NetworkLoggerPlugin 설계: provider를 이용하여 네트워크 호출을 하는데, provider를 생성할 때 plugin을 넣어주면 willSend, didReceive, onSucced, onFail에 관한것을 정의하여 log 확인 용도
  • Moya에서 정의한 protocol인 PluginType 프로토콜은 준수
import Moya

struct NetworkLoggerPlugin: PluginType {
    func willSend(_ request: RequestType, target: TargetType) {
        guard let httpRequest = request.request else {
            print("[HTTP Request] invalid request")
            return
        }

        let url = httpRequest.description
        let method = httpRequest.httpMethod ?? "unknown method"

        /// HTTP Request Summary
        var httpLog = """
                [HTTP Request]
                URL: \(url)
                TARGET: \(target)
                METHOD: \(method)\n
                """

        /// HTTP Request Header
        httpLog.append("HEADER: [\n")
        httpRequest.allHTTPHeaderFields?.forEach {
            httpLog.append("\t\($0): \($1)\n")
        }
        httpLog.append("]\n")

        /// HTTP Request Body
        if let body = httpRequest.httpBody, let bodyString = String(bytes: body, encoding: String.Encoding.utf8) {
            httpLog.append("BODY: \n\(bodyString)\n")
        }
        httpLog.append("[HTTP Request End]")

        print(httpLog)
    }

    func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
        switch result {
        case let .success(response):
            onSuceed(response, target: target, isFromError: false)
        case let .failure(error):
            onFail(error, target: target)
        }
    }

    func onSuceed(_ response: Response, target: TargetType, isFromError: Bool) {
        let request = response.request
        let url = request?.url?.absoluteString ?? "nil"
        let statusCode = response.statusCode

        /// HTTP Response Summary
        var httpLog = """
                [HTTP Response]
                TARGET: \(target)
                URL: \(url)
                STATUS CODE: \(statusCode)\n
                """

        /// HTTP Response Header
        httpLog.append("HEADER: [\n")
        response.response?.allHeaderFields.forEach {
            httpLog.append("\t\($0): \($1)\n")
        }
        httpLog.append("]\n")

        /// HTTP Response Data
        httpLog.append("RESPONSE DATA: \n")
        if let responseString = String(bytes: response.data, encoding: String.Encoding.utf8) {
            httpLog.append("\(responseString)\n")
        }
        httpLog.append("[HTTP Response End]")

        print(httpLog)
    }

    func onFail(_ error: MoyaError, target: TargetType) {
        if let response = error.response {
            onSuceed(response, target: target, isFromError: true)
            return
        }

        /// HTTP Error Summary
        var httpLog = """
                [HTTP Error]
                TARGET: \(target)
                ERRORCODE: \(error.errorCode)\n
                """
        httpLog.append("MESSAGE: \(error.failureReason ?? error.errorDescription ?? "unknown error")\n")
        httpLog.append("[HTTP Error End]")

        print(httpLog)
    }
}

MoyaProvider를 생성하는 Networkable 프로토콜 정의

import Moya

protocol Networkable {
    /// provider객체 생성 시 Moya에서 제공하는 TargetType을 명시해야 하므로 타입 필요
    associatedtype Target: TargetType
    /// DIP를 위해 protocol에 provider객체를 만드는 함수 정의
    static func makeProvider() -> MoyaProvider<Target>
}

extension Networkable {

    static func makeProvider() -> MoyaProvider<Target> {
        /// access token 세팅
        let authPlugin = AccessTokenPlugin { _ in
            return "bear-access-token-sample"
        }
        /// 로그 세팅
        let loggerPlugin = NetworkLoggerPlugin()

  	/// plugin객체를 주입하여 provider 객체 생성
        return MoyaProvider<Target>(plugins: [authPlugin, loggerPlugin])
    }

}

Network 에러 정의

  • Error 프로토콜 준수한 에러타입 정의
import Foundation
import Moya

// back-end 팀과 정의한 에러 내용
enum ServiceError: Error {
    case moyaError(MoyaError)
    case invalidResponse(responseCode: Int, message: String)
    case tokenExpired
    case refreshTokenExpired
    case duplicateLoggedIn(message: String)
}

extension ServiceError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .moyaError(let moyaError):
            return moyaError.localizedDescription
        case let .invalidResponse(_, message):
            return message
        case .tokenExpired:
            return "AccessToken Expired"
        case .refreshTokenExpired:
            return "RefreshToken Expired"
        case let .duplicateLoggedIn(message):
            return message
        }
    }
}

ResponseData 정의

  • common response 정의: codable을 준수하고 Model을 generic으로 선언
struct ResponseData<Model: Codable> {
    struct CommonResponse: Codable {
        let page: Int
        let perPage: String
        let total: Int
        let totalPages: Int
        let data: Model
    }
    
    ...
    
}
  • response를 처리하는 processResponse 메소드 정의: 해당 메소드는 moya의 provider객체를 가지고 api 호출 후 응답을 받은 경우 그 응답을 해당 매소드에서 처리하기위한 용도
    • 파라미터 Result<Response, MoyaError>로 놓고, 반환 타입은 Result<Model?, Error>로 정의
struct ResponseData<Model: Codable> {
    struct CommonResponse: Codable {
        let result: Model
    }

    static func processResponse(_ result: Result<Response, MoyaError>) -> Result<Model?, Error> {
        switch result {
        case .success(let response):
            do {
                // status code가 200...299인 경우만 success로 체크 (아니면 예외발생)
                _ = try response.filterSuccessfulStatusCodes()

                let commonResponse = try JSONDecoder().decode(CommonResponse.self, from: response.data)
                return .success(commonResponse.result)
            } catch {
                return .failure(error)
            }

        case .failure(let error):
            return .failure(error)
        }
    }
    
}

보통 Common response에 responseCode, message, result 형식으로 표현하지만, 예제데이터는 아래이므로 아래 데이터 통째 하나를 result로 보도록 처리

{
   "page":2,
   "per_page":6,
   "total":12,
   "total_pages":2,
   "data":[
      {
         "id":7,
         "email":"michael.lawson@reqres.in",
         "first_name":"Michael",
         "last_name":"Lawson",
         "avatar":"https://reqres.in/img/faces/7-image.jpg"
      },
      {
         "id":8,
         "email":"lindsay.ferguson@reqres.in",
         "first_name":"Lindsay",
         "last_name":"Ferguson",
         "avatar":"https://reqres.in/img/faces/8-image.jpg"
      },
      {
         "id":9,
         "email":"tobias.funke@reqres.in",
         "first_name":"Tobias",
         "last_name":"Funke",
         "avatar":"https://reqres.in/img/faces/9-image.jpg"
      },
      {
         "id":10,
         "email":"byron.fields@reqres.in",
         "first_name":"Byron",
         "last_name":"Fields",
         "avatar":"https://reqres.in/img/faces/10-image.jpg"
      },
      {
         "id":11,
         "email":"george.edwards@reqres.in",
         "first_name":"George",
         "last_name":"Edwards",
         "avatar":"https://reqres.in/img/faces/11-image.jpg"
      },
      {
         "id":12,
         "email":"rachel.howell@reqres.in",
         "first_name":"Rachel",
         "last_name":"Howell",
         "avatar":"https://reqres.in/img/faces/12-image.jpg"
      }
   ],
   "support":{
      "url":"https://reqres.in/#support-heading",
      "text":"To keep ReqRes free, contributions towards server costs are appreciated!"
   }
}
  • CommonResponse를 따르지 않는 resopnse모델 처리를 위한 함수도 추가
struct ResponseData<Model: Codable> {
    struct CommonResponse: Codable {
        let result: Model
    }

    static func processResponse(_ result: Result<Response, MoyaError>) -> Result<Model?, Error> {
        switch result {
        case .success(let response):
            do {
                // status code가 200...299인 경우만 success로 체크 (아니면 예외발생)
                _ = try response.filterSuccessfulStatusCodes()

                let commonResponse = try JSONDecoder().decode(CommonResponse.self, from: response.data)
                return .success(commonResponse.result)
            } catch {
                return .failure(error)
            }

        case .failure(let error):
            return .failure(error)
        }
    }

    // CommonResponse 모델을 따르지 않는 모델을 처리하기 위한 함수
    static func processJSONResponse(_ result: Result<Response, MoyaError>) -> Result<Model?, Error> {
        switch result {
        case .success(let response):
            do {
                let model = try JSONDecoder().decode(Model.self, from: response.data)
                return .success(model)
            } catch {
                return .failure(error)
            }
        case .failure(let error):

            return .failure(error)
        }
    }
}

BaseTargetType정의

  • 개념: Moya에서 제공하는 endpoint에 관해 enum으로 정의하여 편리하게 api호출을 할수 있게 하는 targetType의 base
  • configuration을 Phase별 api 설정 방법 개념 참고
  • base url 확인 후 기입

protocol BaseTargetType: TargetType {
}

extension BaseTargetType {
    var baseURL: URL {
        // Configuration을 통해 phase별 baseURL 설정 방법: https://ios-development.tistory.com/660
//        guard let urlString = Bundle.main.object(forInfoDictionaryKey: "API_URL") as? String else { fatalError("API URL not defined")}
//        gaurd let apiURL = URL(string: urlString) else { fatalError("URL is invalid") }

        return URL(string: "https://reqres.in")!
    }
    
}
  • 공통 Header 정의
import Moya
import UIKit

protocol BaseTargetType: TargetType {
}

extension BaseTargetType {
    var baseURL: URL {
        // Configuration을 통해 phase별 baseURL 설정 방법: https://ios-development.tistory.com/660
//        guard let urlString = Bundle.main.object(forInfoDictionaryKey: "API_URL") as? String else { fatalError("API URL not defined")}
//        gaurd let apiURL = URL(string: urlString) else { fatalError("URL is invalid") }

        return URL(string: "https://reqres.in/api")!
    }

    var headers: [String: String]? {
        var header = ["Content-Type": "application/json"]
        let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
        header["app-device-uuid"] = "uuid"
        header["app-device-model-name"] = UIDevice.current.name
        header["app-device-os-version"] = UIDevice.current.systemVersion
        header["app-device-device-manufacturer"] = "apple"
        header["app-version"] = bundleVersion
        header["app-timezone-id"] = TimeZone.current.identifier
        return header
    }
    
    // BaseTargetType을 상속받는 각 TargetType에서 sampleData를 필수로 구현하지 않아도 되도록 디폴트값 부여
    var sampleData: Data {
        return Data()
    }
}
  • encodable 정의: model을 json으로 serialization하는 함수
extension Encodable {
    func toDictionary() -> [String: Any] {
        do {
            let data = try JSONEncoder().encode(self)
            let dic = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any]
            return dic ?? [:]
        } catch {
            return [:]
        }
    }
}

endpoint 정의

  • request는 `?page=2` 이므로 codable을 준수하는 struct 정의
struct ListUserRequest: Codable {
    let page: Int
}
  • Target 정의
enum ListUserTarget {
    case list(ListUserRequest)
}

extension ListUserTarget: BaseTargetType {
    var path: String {
        switch self {
        case .list: return "/api/users"
        }
    }

    var method: Method {
        switch self {
        case .list: return .get
        }
    }

    var task: Task {
        switch self {
        case .list(let request): return .requestParameters(parameters: request.toDictionary(), encoding: URLEncoding.queryString)
        }
    }
}
  • 주의: requestParameter가 아닌 body에 추가하는 data이면, .requestJSONEncodable 사용
    var task: Task {
        switch self {
//        case .list(let request): return .requestParameters(parameters: request.toDictionary(), encoding: URLEncoding.queryString)
        case .list(let request): return .requestJSONEncodable(request)
        }
    }
  • 주의: bearer token이 필요한 경우 `AccessTokenAuthorizable` 프로토콜을 준수 후 authorizationType 프로퍼티에 .bearer 리턴
import Moya

enum ListUserTarget {
    case list(ListUserRequest)
}

extension ListUserTarget: BaseTargetType, AccessTokenAuthorizable {
    var path: String {
        switch self {
        case .list: return "/api/users"
        }
    }

    var method: Method {
        switch self {
        case .list: return .get
        }
    }

    var task: Task {
        switch self {
        case .list(let request): return .requestParameters(parameters: request.toDictionary(), encoding: URLEncoding.queryString)
        }
    }

    var authorizationType: AuthorizationType? {
        return .bearer
    }
}
  • Response 정의
    • codable 형식의 struct 모델
    • 직접적으로 domain에서 사용되는 모델이므로 domain디렉토리 하위에 위치 (이름도 -Response를 붙이지 않는것을 주의)
    • json을 codable로 변환해주는 사이트는 이곳 참고
struct ListUser: Codable {
    let page, perPage, total, totalPages: Int
    let data: [Datum]
    let support: Support

    enum CodingKeys: String, CodingKey {
        case page
        case perPage = "per_page"
        case total
        case totalPages = "total_pages"
        case data, support
    }

    struct Datum: Codable {
        let id: Int
        let email, firstName, lastName: String
        let avatar: String

        enum CodingKeys: String, CodingKey {
            case id, email
            case firstName = "first_name"
            case lastName = "last_name"
            case avatar
        }
    }

    struct Support: Codable {
        let url: String
        let text: String
    }
}
  • API 정의
import Moya

struct ListUserAPI: Networkable {
    typealias Target = ListUserTarget

    /// page에 해당하는 User 정보 조회
    static func getUserList(request: ListUserRequest, completion: @escaping (_ succeed: ListUser?, _ failed: Error?) -> Void) {
        makeProvider().request(.list(request)) { result in
            switch ResponseData<ListUser>.processJSONResponse(result) {
            case .success(let model): return completion(model, nil)
            case .failure(let error): return completion(nil, error)
            }
        }
    }
}
  • API사용
        let request = ListUserRequest(page: 2)
        ListUserAPI.getUserList(request: request) { response, error in
            guard let response = response else {
                print(error ?? #function)
                return
            }

            let listUser = response
            print(listUser)
        }

 

성공

* 전체 소스 코드: https://github.com/JK0369/NetworkExWithMoya

Comments