핵심 모듈
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
...
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
}
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
}
}
protocol Responsable {
associatedtype Response
}
class Endpoint<R>: RequesteResponsable {
// Response타입을 미리 정의하여, Endpoint객체 하나만 request쪽에 넘기면 request함수의 Response제네릭에 적용
typealias Response = R
...
}
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
}
}
request는 2가지 종류
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)))
}
}
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))
}
}
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()!
}
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
}
...
}
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)
}
}