[Swift] Network Layer 구성하기

장주명·2022년 8월 30일
0

Testable하게 Network Layer를 구성하고자 하였습니다.

Network 레이어 설계

핵심 모듈

  • Endpoint: path, queryPramameters, bodyParameter등의 데이터 객체
  • Provider: URLSession, DataTask를 이용하여 network호출이 이루어 지는 곳
  • Endpoint는 요청, 응답 protocol을 준수하는 상태
  • requestable에는 baseURL, path, method, parameters, ... 같은 정보가 존재
protocol Responsable {
    associatedtype Response
}

// Endpoint 객체를 만들때 Response타입을 명시
class Endpoint<R>: RequesteResponsable {
    typealias Response = R
...

// Provider에서 Endpoint객체를 받으면 따로 Response 타입을 넘기지 않아도 되도록 설계
protocol Provider {
    func request<R: Decodable, E: RequesteResponsable>(with endpoint: E, completion: @escaping (Result<R, Error>) -> Void) where E.Response == R
    ...

Network 구현

  • Request, Response가 Generic하여 하드코딩되지 않은 형태
  • URLSession의 dataTask메소드를 protocol로 선언하여 request/response를 testable하도록 구현

Error 타입 정의

enum NetworkError: Error{
    case unknownError
    case componentsError
    case urlRequestError(Error)
    case serverError(ServerError)
    case emptyData
    case parsingError
    case decodingError(Error)
    
    var errorDescription: String {
        switch self {
        case .unknownError:
            return "알수 없는 에러입니다."
        case .urlRequestError:
            return "URL Request 관련 에러가 발생했습니다."
        case .componentsError:
            return "URL components 관련 에러가 발생했습니다."
        case .serverError(let serverError):
            return "Status코드 에러입니다. \(serverError) Code: \(serverError.rawValue)"
        case .emptyData:
            return "데이터가 없습니다."
        case .parsingError:
            return "데이터 Parsing 중 에러가 발생했습니다."
        case .decodingError:
            return "Decoding 에러가 발생했습니다."
        }
    }
}

enum ServerError: Int {
    case unkonown
    case badRequest = 400
    case unauthorized = 401
    case forbidden = 403
    case notFound = 404
    case unsplashError = 500
    case unsplashError2 = 503
}

Endpoint 정의

  • Requestable
enum HttpMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}

protocol Requestable {
    var baseURL: String { get }
    var path: String { get }
    var method: HttpMethod { get }
    var queryParameters: Encodable? { get }
    var bodyParameters: Encodable? { get }
    var headers: [String: String]? { get }
    var sampleData: Data? { get }
}

extension Requestable {
    func getUrlRequest() throws -> URLRequest {
        let url = try makeUrl()
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = method.rawValue
        
        if let bodyParameters = try bodyParameters?.toDicionary() {
            if !bodyParameters.isEmpty {
                urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: bodyParameters)
            }
        }
        
        headers?.forEach { key,value in
            urlRequest.setValue(value, forHTTPHeaderField: "\(key)")
        }
        
        return urlRequest
    }
    
    func makeUrl() throws -> URL {
        let fullPath = "\(baseURL)\(path)"
        guard var urlComponents = URLComponents(string: fullPath) else { throw NetworkError.componentsError}
        
        var urlQueryItems = [URLQueryItem]()
        if let queryParameters = try queryParameters?.toDicionary() {
            queryParameters.forEach { key,value in
                urlQueryItems.append(URLQueryItem(name: key, value: "\(value)"))
            }
        }
        
        urlComponents.queryItems = urlQueryItems.isEmpty ? nil : urlQueryItems
        guard let url = urlComponents.url else { throw NetworkError.componentsError }
        return url
    }
}
  • Responsable: Endpoint를 생성할 때 타입을 주입하여, 뒤에 나올 provider의 request제네릭에 적용
protocol Responsable {
    associatedtype Response
}

class Endpoint<R>: RequesteResponsable {
	// Response타입을 미리 정의하여, Endpoint객체 하나만 request쪽에 넘기면 request함수의 Response제네릭에 적용
    typealias Response = R
    ...
}
  • Endpoint 구현: Requestable, Responsable의 성격을 가지고 있는 클래스
protocol RequestResponsable: Requestable, Responsable {}

class EndPoint<R>: RequestResponsable {
    typealias Response = R
    var baseURL: String
    var path: String
    var method: HttpMethod
    var queryParameters: Encodable?
    var bodyParameters: Encodable?
    var headers: [String:String]?
    var sampleData: Data?
    init(baseURL: String, path: String = "", method: HttpMethod = .get, queryParameters: Encodable? = nil, bodyParameters: Encodable? = nil, headers:[String:String]? = nil, sampleData: Data? = nil) {
        self.baseURL = baseURL
        self.path = path
        self.method = method
        self.queryParameters = queryParameters
        self.bodyParameters = bodyParameters
        self.headers = headers
        self.sampleData = sampleData
    }
}

Provider 정의

request는 2가지 종류

  • Encodable한 Request모델을 통해서 Decodable한 Response를 받는 request
  • 단순히 URL을 request로 주어, Data를 얻는 request (ex - 이미지 url을 넣고 이미지 Data 얻어오는 경우 사용)
protocol Provider {
    func request<R: Decodable, E: RequestResponsable>(with endpoint: E, completion: @escaping (Result<R, Error>) -> Void) where E.Response == R

    func request(_ url: URL, completion: @escaping (Result<Data, Error>) -> ())
}

class ProviderImpl: Provider {
    let session: URLSessionable
    
    init(session: URLSessionable = URLSession.shared) {
        self.session = session
    }
    
    func request<R, E>(with endpoint: E, completion: @escaping (Result<R, Error>) -> Void) where R : Decodable, R == E.Response, E : RequestResponsable {
        do {
            let urlRequest = try endpoint.getUrlRequest()
            
            session.dataTask(with: urlRequest) { [weak self] data, response, error in
                self?.checkError(with: data, response, error) { result in
                    switch result {
                    case .success(let data):
                        completion(Decoder<R>().decode(data: data))
                    case .failure(let error):
                        completion(.failure(error))
                    }
                }
            }.resume()
            
        } catch {
            completion(.failure(NetworkError.urlRequestError(error)))
        }
    }
    
    func request(_ url: URL, completion: @escaping (Result<Data, Error>) -> ()) {
        session.dataTask(with: url) { [weak self] data, response, error in
            self?.checkError(with: data, response, error, completion: { result in
                completion(result)
            })
        }.resume()
    }
    
    private func checkError(with data: Data?, _ response: URLResponse?, _ error: Error?, completion: @escaping (Result<Data, Error>) -> ()) {
        if let error = error {
            completion(.failure(error))
            return
        }
        
        guard let response = response as? HTTPURLResponse else {
            completion(.failure(NetworkError.unknownError))
            return
        }
        
        guard (200...299).contains(response.statusCode) else {
            completion(.failure(NetworkError.serverError(ServerError(rawValue: response.statusCode) ?? .unkonown)))
            return
        }
        
        guard let data = data else {
            completion(.failure(NetworkError.emptyData))
            return
        }
        
        completion(.success((data)))
    }
}

호출하는 방법

  • APIEndpoints를 정의하여 도메인에 종속된 baseURL, path등을 정의
struct APIEndpoints {
    static func getPhotosInfo(with photoInfoRequestDTO: PhotoInfoRequestDTO) -> EndPoint<[PhotoInfoResponseDTO]> {
        return EndPoint(baseURL: "https://api.unsplash.com/",
                        path: "photos",
                        method: .get,
                        queryParameters: photoInfoRequestDTO,
                        headers: ["Authorization": "Client-ID \(Constants.accessKey)"]
                        ,sampleData: NetworkResponseMock.photoList)
    }
    
    static func getImage(with url: String) -> EndPoint<Data> {
        return EndPoint(baseURL: url, sampleData: NetworkResponseMock.image)
    }
    
    
}
  • 호출하여 사용
let endpoint = APIEndpoints.getPhotosInfo(with: photoListRequestDTO)
        currentPage += 1
        provider.request(with: endpoint) { result in
            switch result {
            case .success(let responesDTO):
                var photoInfoList = [PhotoInfo]()
                responesDTO.forEach { info in
                    var model = info.toDomain()
                    let request: NSFetchRequest<ImageData> = ImageData.fetchRequest()
                    self.coreDataStarge.fetch(request: request) { result in
                        switch result {
                        case .success(let imageDatas):
                            imageDatas.forEach { imageData in
                                if imageData.id == model.id {
                                    model.isSaved = true
                                }
                            }
                            photoInfoList.append(model)
                        case .failure(let error):
                            completion(.failure(error))
                        }
                    }
                }
                completion(.success(photoInfoList))
            case .failure(let error):
                completion(.failure(error))
            }
        }

네트워크 Test 코드

  • mock response로 사용될 데이터를 APIEndpoints에서 sampleData에 사용되는 데이터 미리 준비
struct NetworkResponseMock {
    static let photoList: Data = """
                            [
                                {
                                    "id": "siy3D89AqJw",
                                    "created_at": "2022-07-07T17:17:44Z",
                                    "updated_at": "2022-08-30T12:32:39Z",
                                    "promoted_at": "2022-07-07T18:08:01Z",
                                    "width": 3858,
                                    "height": 5139,
                                    "color": "#262626",
                                    ...
                                }
                            ]
                            """.data(using: .utf8)!
    static let photoSearch: Data = """
                                    {
                                       "total":133,
                                       "total_pages":7,
                                       "results":[
                                          {
                                             "id":"eOLpJytrbsQ",
                                             "created_at":"2014-11-18T14:35:36-05:00",
                                             "width":4000,
                                             "height":3000,
                                             "color":"#A7A2A1",
                                             ...
                                    }
                                    """.data(using: .utf8)!
    static let image: Data = UIImage(systemName: "square")!.pngData()!
}
  • 설계할때 URLSession을 protocol을 따르도록 구현했으므로, URLSession에 mock을 넣어서 response데이터를 조작할 수 있으므로 testable 성격 확보
protocol URLSessionable {
    func dataTask(with request: URLRequest,
                  completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
    func dataTask(with url: URL,
                  completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
}

extension URLSession: URLSessionable {}

// 사용하는 곳
class ProviderImpl: Provider {

    let session: URLSessionable
    init(session: URLSessionable = URLSession.shared) {
        self.session = session
    }
...
}
  • URLSessionDataTask를 준수하는 테스트용 MockURLSessionDataTask 정의
    resumeDidCall: resume이 불리면 실행되는 closure블록 (외부에서 정의하면, resume이 불리는 타이밍에 바로 실행)
class MockURLSessionDataTask: URLSessionDataTask {

    var resumeDidCall: (() -> ())?

    override func resume() {
        // 주의: super.resume()호출하면 실제 resume()이 호출되므로 주의
        resumeDidCall?()
    }
}
  • 테스트 코드 작성
class PicterestNetworkTests: XCTestCase {
    
    var provider: Provider!
    
    override func setUpWithError() throws {
        provider = ProviderImpl(session: MockURLSession())
    }


    func test_fetchPhotoList_whenSuccess_thenProcessRight() {
        // async테스트를 위해서 XCTestExpectation 사용
        let expectation = XCTestExpectation()

        let endpoint = APIEndpoints.getPhotosInfo(with: .init(page: 1))
        let endpointJson = endpoint.sampleData!
        print(endpointJson)
        let responseMock = try? JSONDecoder().decode([PhotoInfoResponseDTO].self, from: endpointJson)

        provider.request(with: endpoint) { result in
            switch result {
            case .success(let response):
                XCTAssertEqual(response.first?.id, responseMock?.first?.id)
            case .failure:
                XCTFail()
            }
            expectation.fulfill()
        }

        wait(for: [expectation], timeout: 2.0)
    }

    func test_fetchPhotoList_whenFailed_thenProcessRight() {
        provider = ProviderImpl(session: MockURLSession(makeRequestFail: true))
        let expectation = XCTestExpectation()

        let endpoint = APIEndpoints.getPhotosInfo(with: .init(page: 1))

        provider.request(with: endpoint) { result in
            switch result {
            case .success:
                XCTFail()
            case .failure(let error):
                XCTAssertEqual(error.localizedDescription, "status코드가 200~299가 아닙니다.")
            }
            expectation.fulfill()
        }

        wait(for: [expectation], timeout: 2.0)
    }

}

  • 테스트 성공

참조 - https://ios-development.tistory.com/719

profile
flutter & ios 개발자

0개의 댓글