API 통신 코드 작성 시마다 반복되는 코드 줄이 상당하고 공통적으로 사용하는 변수 (baseUrl, header ...) 및 OAuth 인증 시스템을 위한 Interceptor를 구현하기 위해 Network 관련 함수를 Layer를 설계하여 Refactoring을 진행하기로 결정하였습니다.
ex)
func getExampleModel() -> Single<Result<ExampleModel, APIError>> {
return Single.create { single in
guard let url = URL(string: "\(APIParameters.shared.baseUrl)/api/examples") else {
single(.success(.failure(APIError(statusCode: -1, description: "uri error"))))
return Disposables.create()
}
APIParameters.shared.session
.request(url, method: .get, parameters: nil, headers: APIParameters.shared.authorizedHeader)
.validate(statusCode: 200 ..< 300)
.responseJSON(completionHandler: { response in
switch response.result {
case .success:
if let data = response.data {
do {
let model = try JSONDecoder().decode(ExampleModel.self, from: data)
single(.success(.success(model)))
} catch {
single(.success(.failure(APIError(statusCode: -1, description: "parsing error"))))
}
}
case let .failure(error):
guard let statusCode = response.response?.statusCode else {
single(.success(.failure(APIError(statusCode: error._code,
description: error.errorDescription))))
return
}
single(.success(.failure(APIError(statusCode: statusCode, description: error.errorDescription))))
}
})
.resume()
return Disposables.create()
}
}
Network Layer에 대해 research 중 열거형을 사용하여 타입이 안전한 방식으로 네트워크 요청을 캡슐화한 Network Layer를 제공하는 **Moya
라는 라이브러리를 알게 되었습니다.
**Moya
라이브러리를 이용하여 Refactoring을 진행하려 했으나 Interceptor같은 세부 기능에 대한 research 결과가 많지 않았고 Moya
도 Alamofire를 이용해 Network 통신이 진행되지만 Alamofire를 직접 조금 더 다뤄보고 싶다는 욕심, 최신 Alamofire 버전을 지원하지 않는다는 점에서 직접 Custom하여 진행하기로 하였습니다.
Custom 과정은 아래 블로그를 참고하여 진행했습니다
https://ios-development.tistory.com/731?category=899471
우선 Refresh Token을 이용한 Access Token 재발급 (OAuth) 및 API 공통 오류 처리를 위해 Interceptor 구현을 진행했습니다.
해당 내용은 https://www.notion.so/Alamofire-Advanced-Usage-f3fab086136646d9a488667317d3efd8 에서 확인이 가능합니다.
Target Type은 API들이 공통적으로 가지고 있는 end point를 가지고 있는 module입니다
충전했'오'에서 사용되는 API에서는 개별적인 header 및 encoding을 정의할 필요가 있어 TargetType에 추가하여 진행하였습니다.
parameter들은 Encodable을 준수하는 struct를 받도록 설계했으며, extension으로 toDictionary() 메소드도 정의하였습니다.
import Alamofire
import Foundation
protocol TargetType: URLRequestConvertible {
var baseURL: String { get }
var method: HTTPMethod { get }
var header: HTTPHeaders { get }
var path: String { get }
var parameters: RequestParams? { get }
var encoding: ParameterEncoding { get }
}
extension TargetType {
// URLRequestConvertible 구현
func asURLRequest() throws -> URLRequest {
let url = try baseURL.asURL()
var urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method)
urlRequest.headers = header
switch parameters {
case let .query(request):
let params = request?.toDictionary() ?? [:]
let queryParams = params.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
var components = URLComponents(string: url.appendingPathComponent(path).absoluteString)
components?.queryItems = queryParams
urlRequest.url = components?.url
return try encoding.encode(urlRequest, with: params)
case let .body(request):
let params = request?.toDictionary() ?? [:]
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: params, options: [])
return try encoding.encode(urlRequest, with: params)
case .none:
return urlRequest
}
return urlRequest
}
}
enum RequestParams {
case query(_ parameter: Encodable?)
case body(_ parameter: Encodable?)
}
extension Encodable {
func toDictionary() -> [String: Any] {
guard let data = try? JSONEncoder().encode(self),
let jsonData = try? JSONSerialization.jsonObject(with: data),
let dictionaryData = jsonData as? [String: Any] else { return [:] }
return dictionaryData
}
}
Model의 경우 Domain / Response (Decodable) / Request (Encodable)의 형태로 Refactoring하려 했으나 아직까지 Domain / Response 을 구분하여 사용할만큼 API response와 client에서 사용하는 model과의 차이가 없어 Domain(Docodable) / Request (Encodable) 로 우선 Refactoring을 진행하였습니다
ex) 회원가입 Request
import Foundation
struct SigninRequest: Encodable {
let email: String?
let password: String?
enum CodingKeys: String, CodingKey {
case email, password
}
}
아래 code는 Station Domain에서 사용되는 end-point를 정의한 StationTarget 파일입니다.
import Alamofire
enum ExampleTarget {
case getExamples(GetExamplesRequest)
case addExample(AddExampleRequest)
case deleteExample(DeleteExampleRequest)
}
extension ExampleTarget: TargetType {
var baseURL: String {
return Constant.baseURl
}
var method: HTTPMethod {
switch self {
case .getExamples:
return .get
case .addExample:
return .post
case .deleteExample:
return .delete
}
}
var path: String {
switch self {
case .getExamples:
return "/examples"
case .addExample:
return "/examples"
case let .deleteExample(request):
return "/examples/\(request.exampleId)"
}
}
var header: HTTPHeaders {
return Constant.header
}
var parameters: RequestParams? {
switch self {
case .getExamples: return nil
case let .addExample(request): return .body(request)
case .deleteExample: return nil
}
}
var encoding: ParameterEncoding {
switch self {
case .getExamples:
return URLEncoding.default
case .addExample, .deleteExample:
return JSONEncoding.default
}
}
}
또한 모든 domain에서 공통적으로 사용하는 request파일을 generic type을 이용하여 API Class 내에 정의하였습니다.
import Alamofire
import RxSwift
class API {
static let session: Session = {
let configuration = URLSessionConfiguration.af.default
configuration.timeoutIntervalForRequest = 10
configuration.timeoutIntervalForResource = 10
let apiLogger = APIEventLogger()
return Session(configuration: configuration, eventMonitors: [apiLogger])
}()
func request<T: Codable>(_ urlConvertible: URLRequestConvertible) -> Single<Result<T, APIError>> {
return Single.create { single in
let request = API.session
.request(urlConvertible, interceptor: MyRequestInterceptor())
.validate(statusCode: 200 ..< 300)
.responseDecodable(of: T.self) { response in
switch response.result {
case let .success(data):
single(.success(.success(data)))
case let .failure(error):
single(.success(.failure(APIError(statusCode: response.response?.statusCode,
description: error.errorDescription))))
}
}
return Disposables.create {
request.cancel()
}
}
}
func request(_ urlConvertible: URLRequestConvertible) -> Single<Result<Void, APIError>> {
return Single.create { single in
let request = API.session
.request(urlConvertible, interceptor: MyRequestInterceptor())
.validate(statusCode: 200 ..< 300)
.response { response in
switch response.result {
case .success:
single(.success(.success(())))
case let .failure(error):
single(.success(.failure(APIError(statusCode: response.response?.statusCode,
description: error.errorDescription))))
}
}
return Disposables.create {
request.cancel()
}
}
}
}
위에서 ExampleTarget에서 end point 정의 후 request 함수를 가지고 있는 API protocol을 채택하면 Example Domain API를 간단하게, 중복되는 code 없이 구현이 가능합니다.
import Alamofire
import RxSwift
class ExampleAPI: API {
static let shared = ExampleAPI()
private let bag = DisposeBag()
override private init() {}
func getExamples() -> Single<Result<[ExampleModel], APIError>> {
return request(ExampleTarget.getExamples)
}
func addExample(_ requestModel: AddExampleRequest) -> Single<Result<ExampleModel, APIError>> {
return request(ExampleTarget.addExample(requestModel))
}
func deleteExample_ requestModel: DeleteExampleRequest) -> Single<Result<Void, APIError>> {
return request(ExampleTarget.deleteExample(requestModel))
}
}
또한 공통적으로 사용하는 변수들을 정적 변수를 통해 정의하였으며, baseURI의 경우 Debug 시 변동하여 사용하는 만큼 .gitignore 파일에 추가하여 remote repository에 변동되지 않도록 설정하였습니다
import Alamofire
import Foundation
struct Constant {
static let baseURl = "http://projectohbackend-env-1.eba-k5dneaqn.ap-northeast-2.elasticbeanstalk.com/api"
static let header: HTTPHeaders = [
.init(name: "Content-Type", value: "application/json"),
.init(name: "Accept", value: "application/json"),
]
}