Skip to content

Moya와 비슷한 API Manager 만들기

DOHYUN CHUNG edited this page Sep 4, 2022 · 6 revisions

What is Moya

Moya의 아이디어는 아래의 그림으로 해결된다. 스크린샷 2022-09-02 오후 5 44 53

Moya는 URLSession을 추상화한 AlamoFire을 다시 추상화한 프레임워크로 Network Layer를 탬플릿화해서 재사용성을 높히고, 개발자가 request, response에 신경쓰도록 한다.

MOMO의 네트워크 레이어

사실 Moya를 사용했어도 되었지만, Moya를 사용하지 않고, Moya 처럼 구현해보자는 취지, 또한 Moya의 모든 기능을 쓰지 않을 것인데 굳이 서드파티가 필요할까 라는 생각으로 Moya와 비슷한 MOMO 네트워크 레이어를 구축했다.

APIable

protocol APIable {
  var contentType: ContentType { get }
  var requestType: RequestType { get }
  var encodingType: EncodingType {get}
  var header: [String: String]? { get }
  var url: String { get }
  var param: [String: String?]? { get }
}

APIable 프로토콜을 채택하면, request에 필요한 정보를 작성해야한다. EncodingType에는 Momo에서 쓰이는 방식은 URLEncoding과 JSONEncoding이 존재한다. RequestType은 CRUD가 REST형태로 되어있다. ContentType에도 각각 contentType에 대한 header가 만들어져 있다.

enum EncodingType {
  case URLEncoding
  case JSONEncoding
}

enum RequestType: String {
  case get = "GET"
  case post = "POST"
  case delete = "DELETE"
  case patch = "PATCH"
  case put = "PUT"
  
  var method: String {
    return self.rawValue
  }
}

enum ContentType {
  case multiPartForm
  case jsonData
  case HTMLform
  case noBody
  
  var description: String {
    switch self {
    case .multiPartForm:
      return "multipart/form-data"
    case .jsonData:
      return "aplication/json"
    case .HTMLform:
      return "application/x-www-form-urlencoded"
    case .noBody:
      return ""
    }
  }

GetApi

GetApi은 APIable 채택한 enum이다. 각 case는 모두 해당 APIable에 선언되어있는 변수들이 구현되어야 한다. 또한 request에 필요한 param은 모두 enumassociate value로 받는다.

enum GetApi: APIable {
  
  case loginGet(token: String)
  case userGet(token: String)
  case policyGet(token: String, keyword: String?, location: String?, category: Filter?, page: Int)

}

case에 맞게 APIable에 선언된 변수의 구현이 필요하다.

ContentType

var contentType: ContentType {
    switch self {
    case .loginGet, .userGet, .policyGet:
      return .jsonData
    default:
      return .noBody
    }
  }

EncodingType

var encodingType: EncodingType {
    switch self {
    case .loginGet, .userGet:
      return .JSONEncoding
    case .policyGet:
      return .URLEncoding
    default:
      return .JSONEncoding
    }
  }

requestType

 var requestType: RequestType {
    return .get
  }

URL

var url: String {
    switch self {
    case .policyGet:
      return makePathtoURL(path: "/policy")
    case .loginGet:
      return makePathtoURL(path: "/auth/login")
    case .userGet:
      return makePathtoURL(path: "/member")
  }
}

param

var param: [String : String?]? {
    switch self {
    case .loginGet(_), .userGet(_):
      return nil
    case .policyGet(_, let keyword, let location, let category, let page):
      return ["keyword": keyword, "location": location, "category": category?.rawValue, "page": String(page)]
    default:
      return nil
    }
  }

header

var header: [String : String]? {
    switch self {
    case .policyGet(let token, _, _, _, _), .loginGet(let token), .userGet(let token):
      return [ "Authorization" : "Bearer \(token)"]
    default:
      return nil
    }
  }

그렇다면 이것을 어떻게 request로 변환?

NetworkManager 클래스에는 request함수가 존재한다. request 함수 내에서 위에서 선언했던 APIable 채택한 Enum case 중 하나를 넣어주면 그 정보를 토대로 request를 만들어 서버와 통신한다. 함수는 func request(apiModel: APIable, completion: @escaping URLSessionResult) -> URLSessionTaskProtocol? 이렇게 생겼다. APIable을 채택하는 객체를 받고, completion을 통해서 통신 이후에 하려는 동작을 구현해야한다. 그리고 return은 URLSessionTaskProtocol?이다. URLSessionResult은 typealias로서, typealias URLSessionResult = ((Result<Data, Error>) -> Void)이다.

EncodingType

switch apiModel.encodingType {
      case .URLEncoding: // urlEncoding.QueryString형식으로 url 만들기
        guard let tempUrl = URL.URLEncodingType(parameters: apiModel.param, url: apiModel.url) else {
          completion(.failure(NetworkError.invalidURL))
          return nil
        }
        url = tempUrl
          
      case .JSONEncoding: //JSONEncoding일때는 그냥 url만들기
        guard let tempUrl = URL(string: apiModel.url) else {
          completion(.failure(NetworkError.invalidURL))
          return nil
        }
        url = tempUrl
          
      default: // formdata 돌릴때 그냥 URL를 만들기
          guard let tempUrl = URL(string: apiModel.url) else {
            completion(.failure(NetworkError.invalidURL))
            return nil
          }
          url = tempUrl
      }

JSONEncoding과 URLEncoding을 나눈다. default는 formdata라고 생각하고, 그냥 URL를 만든다. JSONEncoding의 경우, JSONBody에 파라미터를 넣어서 전달하는 방식이기에, URL은 그냥 사용해되 무방하다. 하지만 path variable일 경우는 애초에 Enum에서 처리를 해서 나온다. URLEncoding의 경우, URLEncoding을 해야한다. 그렇기에 param을 url과 함께 넘겨 encoding을 한다. 이로 인해 request를 날릴 url이 정해진다.

httpMethod, contentType, header

var request = URLRequest(url: url)
request.httpMethod = apiModel.requestType.method

switch apiModel.contentType {
      case .multiPartForm:
        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
      case .jsonData:
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
      case .HTMLform:
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
      case .noBody:
        break
     }

if let header = apiModel.header {
        header.forEach { request.addValue($1, forHTTPHeaderField: $0)}
      }

URLRequest 객체를 만들어준 뒤에, URLRequest 객체의 프로퍼티를 채워준다. 그리고 contentType에 따라서 header를 넣어주고, 그 외의 header가 있을 경우 역시 추가해준다.

HttpBody

HTTPbody를 만들때, contentType에 따라서 Httpbody가 다르게 만들어진다. JSONdata는 JSON형식으로 인코딩해서 Body를 만든다. multipartForm, HTMLForm 등 다양하다.

request.httpBody = createDataBody(parameter: apiModel.param, contentType: apiModel.contentType, url: apiModel.url)

private func createDataBody(parameter: [String: String?]?, contentType: ContentType, url: String) -> Data? {
    var body = Data()
    let lineBreak = "\r\n"
    
    if let modelParameter = parameter {
      switch contentType {
      case .multiPartForm:
        for (key, value) in modelParameter {
          body.append(convertTextField(key: key, value: "\(value ?? "")"))
        }
        body.append("--\(boundary)--\(lineBreak)")
      case .jsonData:
        if let data = coder.encode(parameters: parameter){
          body = data
        }
      case .HTMLform:
        if let data = coder.encode(parameters: parameter, url: url){
          body = data
        }
      case .noBody:
        return nil
      }
      return body
    } else {
      return nil
    }
  }

Task

URLSession으로 통신할때 마지막은 Task를 만든 다음, Task 객체를 resume() 시키는 것이다. 그리고 request함수의 return 형식이 URLSessionTaskProtocol? 이기에, 이에 맞춰서 return을 시킨다.

  let task = session.makeDataTask(with: request) { data, response, error in
        
        if let error = error {
          completion(.failure(error))
          return
        }
        
        guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode) else {
          completion(.failure(NetworkError.failResponse))
          return
        }
        
        guard let data = data else {
          completion(.failure(NetworkError.invalidData))
          return
        }
        completion(.success(data))
        
      }
      
      task.resume()
      return task

Rxswift로 래핑하기

RxSwift를 도입하면서, 위의 request 함수를 Single을 return 하도록 변경했다.

func request(apiModel: APIable) -> Single<Data> {
    return Single<Data>.create { (single) -> Disposable in
      let request = self.request(apiModel: apiModel) { result in
        switch result {
        case .success(let data):
          single(.success(data))
        case .failure(let error):
          single(.failure(error))
        }
      }
      return Disposables.create()
    }
  }

Referecences

https://github.com/Moya/Moya https://velog.io/@dlskawns96/iOS-Moya%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%82%B9-Swift-Http-%ED%86%B5%EC%8B%A0