diff --git a/14th-team5-iOS/App/Sources/Presentation/Comment/Reactor/CommentViewReactor.swift b/14th-team5-iOS/App/Sources/Presentation/Comment/Reactor/CommentViewReactor.swift index d4e0238e8..b2683c4e0 100644 --- a/14th-team5-iOS/App/Sources/Presentation/Comment/Reactor/CommentViewReactor.swift +++ b/14th-team5-iOS/App/Sources/Presentation/Comment/Reactor/CommentViewReactor.swift @@ -138,15 +138,20 @@ final public class CommentViewReactor: Reactor { Observable.just(.scrollTableToLast(true)) ) } - .catch { [weak self] error -> Observable in - Haptic.notification(type: .error) - self?.navigator.showFetchFailureToast() - return Observable.concat( - Observable.just(.setComments([])), - Observable.just(.setHiddenTablePrgressHud(true)), - Observable.just(.setHiddenNoneCommentView(true)), - Observable.just(.setHiddenFetchFailureView(false)) - ) + .catchWorkerError(with: self) { + switch $1 { + case .networkFailure: + Haptic.notification(type: .error) + $0.navigator.showFetchFailureToast() + return Observable.concat( + Observable.just(.setComments([])), + Observable.just(.setHiddenTablePrgressHud(true)), + Observable.just(.setHiddenNoneCommentView(true)), + Observable.just(.setHiddenFetchFailureView(false)) + ) + + default: return Observable.empty() + } } ) diff --git a/14th-team5-iOS/Core/Sources/Extensions/Dictionary+Ext.swift b/14th-team5-iOS/Core/Sources/Extensions/Dictionary+Ext.swift new file mode 100644 index 000000000..5d7d83cf0 --- /dev/null +++ b/14th-team5-iOS/Core/Sources/Extensions/Dictionary+Ext.swift @@ -0,0 +1,16 @@ +// +// Dictionary+Ext.swift +// Core +// +// Created by 김건우 on 10/2/24. +// + +import Foundation + +public extension Dictionary where Key: RawRepresentable, Value: RawRepresentable { + + func toQueryParameters() -> String { + map { (key, value) in "\(key.rawValue)=\(value.rawValue)" }.joined(separator: "&") + } + +} diff --git a/14th-team5-iOS/Core/Sources/Extensions/Observable+Ext.swift b/14th-team5-iOS/Core/Sources/Extensions/Observable+Ext.swift deleted file mode 100644 index b71e5c053..000000000 --- a/14th-team5-iOS/Core/Sources/Extensions/Observable+Ext.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// Observable+Ext.swift -// Core -// -// Created by 김건우 on 9/26/24. -// - -import Foundation - -import Alamofire -import RxSwift - - -public extension Observable where Element == (HTTPURLResponse, Data) { - - /// 상태 코드가 올바른지 검사합니다. - /// - /// 상태 코드가 `range` 매개변수로 주어진 범위 내에 있다면 next 항목을 반환하고, 그렇지 않다면 error 항목을 방출합니다. - /// - Parameter range: 정상 상태 코드 범위 - /// - Returns: Observable\ - /// - /// - Authors: 김소월 - func validate(statusCode range: Range = 200..<300) -> Observable { - flatMap { element -> Observable in - Observable.create { observer in - let statusCode = element.0.statusCode - if range ~= statusCode { - observer.onNext(element.1) - } else { - observer.onError(AFError.responseValidationFailed(reason: .unacceptableStatusCode(code: statusCode))) - } - return Disposables.create() - } - } - } - -} - - -public extension Observable where Element == Data { - - /// next 항목을 디코딩합니다. - /// - Parameters: - /// - type: 디코딩하고자 하는 타입 - /// - decoder: JSONDecoder 객체 - /// - Returns: Observable\ - /// - /// - Authors: 김소월 - func decode( - _ type: T.Type, - using decoder: JSONDecoder = JSONDecoder() - ) -> Observable { - flatMap { element -> Observable in - Observable.create { observer in - do { - let decodedData = try decoder.decode(type, from: element) - observer.onNext(decodedData) - } catch { - observer.onError(AFError.responseSerializationFailed(reason: .decodingFailed(error: NSError()))) - } - return Disposables.create() - } - } - } - -} diff --git a/14th-team5-iOS/Data/Sources/APIs/BBAPI.swift b/14th-team5-iOS/Data/Sources/APIs/BBAPI.swift deleted file mode 100644 index 89e150a71..000000000 --- a/14th-team5-iOS/Data/Sources/APIs/BBAPI.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// BaseAPISpec.swift -// BBNetwork -// -// Created by 김건우 on 9/25/24. -// - -import Foundation - -import Alamofire - -public protocol BBAPI { - var spec: BBAPISpec { get } -} diff --git a/14th-team5-iOS/Data/Sources/APIs/BBAPIConfiguration.swift b/14th-team5-iOS/Data/Sources/APIs/BBAPIConfiguration.swift deleted file mode 100644 index 4458ee334..000000000 --- a/14th-team5-iOS/Data/Sources/APIs/BBAPIConfiguration.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// BBAPIConfiguration.swift -// BBNetwork -// -// Created by 김건우 on 9/25/24. -// - -import Foundation - -import Alamofire - -/// BBAPI의 설정값입니다. -public struct BBAPIConfiguration { - - /// 기초 URL을 반환합니다. 빌드 환경에 따라 반환되는 URL이 달라집니다. - public static var baseUrl: String = { - #if PRD - return "https://api.no5ing.kr/v1" - #else - return "https://dev.api.no5ing.kr/v1" - #endif - }() - -} diff --git a/14th-team5-iOS/Data/Sources/APIs/BBAPIWorker.swift b/14th-team5-iOS/Data/Sources/APIs/BBAPIWorker.swift deleted file mode 100644 index d7dd6f78c..000000000 --- a/14th-team5-iOS/Data/Sources/APIs/BBAPIWorker.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// BBAPIWorker.swift -// BBNetwork -// -// Created by 김건우 on 9/25/24. -// - -import Core -import Foundation - -import Alamofire -import RxAlamofire -import RxCocoa -import RxSwift - - -// MARK: - API Worker - -class BBAPIWorker { - - /// 주어진 Spec을 토대로 HTTP 통신을 수행합니다. - /// - /// HTTP 통신에 성공한다면 next 항목을 방출하고, 실패한다면 error 항목을 방출합니다. - /// - /// 해당 메서드가 반환되는 시점에서 스트림이 메인 쓰레드로 바뀌며 `observe(on:)` 연산자를 호출할 필요가 없습니다. - /// - /// - Parameters: - /// - spec: BBAPISepc 타입 객체 - /// - type: 디코딩하고자 하는 타입 - /// - decoder: JSONDecoder 객체 - /// - Returns: Single\ - func request( - _ spec: BBAPISpec, - of type: D.Type, - using decoder: JSONDecoder = JSONDecoder() - ) -> Observable where D: Decodable { - let urlRequest = createURLRequest(spec) - - return request(urlRequest, of: type, using: decoder) - } - - - private func request( - _ urlRequest: any URLRequestConvertible, - of type: D.Type, - using decoder: JSONDecoder = JSONDecoder() - ) -> Observable where D: Decodable { - - return BBSession.default.rx.request(urlRequest: urlRequest) - .responseData() - .observe(on: RxScheduler.main) - .validate() - .decode(type, using: decoder) - - } - -} - - -// MARK: - Extensions - -private extension BBAPIWorker { - - func createURLRequest(_ spec: BBAPISpec) -> URLRequest { - let url = URL(string: spec.urlString)! - var urlRequest = URLRequest(url: url) - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.httpMethod = spec.method.asHTTPMethod.rawValue - urlRequest.headers = spec.headers.asHTTPHeaders - urlRequest.httpBody = spec.requestBody?.encodeToData() - urlRequest.timeoutInterval = 5 - return urlRequest - } - -} diff --git a/14th-team5-iOS/Data/Sources/APIs/BBSession.swift b/14th-team5-iOS/Data/Sources/APIs/BBSession.swift deleted file mode 100644 index 2ab7fdfb1..000000000 --- a/14th-team5-iOS/Data/Sources/APIs/BBSession.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// File.swift -// BBNetwork -// -// Created by 김건우 on 9/25/24. -// - -import Foundation - -import Alamofire - -// MARK: - BBSession - -struct BBSession { - - static let `default`: Session = { - let eventMonitor = BBEventMonitor() - let interceptor = BBIntercepter() - let session = Session(interceptor: interceptor, eventMonitors: [eventMonitor]) - return session - }() - -} diff --git a/14th-team5-iOS/Data/Sources/APIs/Comment/CommentAPI/CommentAPIWorker.swift b/14th-team5-iOS/Data/Sources/APIs/Comment/CommentAPI/CommentAPIWorker.swift index 61b64caea..4c6a1dbe6 100644 --- a/14th-team5-iOS/Data/Sources/APIs/Comment/CommentAPI/CommentAPIWorker.swift +++ b/14th-team5-iOS/Data/Sources/APIs/Comment/CommentAPI/CommentAPIWorker.swift @@ -33,7 +33,7 @@ extension CommentAPIWorker { let sort = query.sort.rawValue let spec = CommentAPIs.fetchPostComment(postId: postId, page: page, size: size, sort: sort).spec - return request(spec, of: PaginationResponsePostCommentResponseDTO.self) + return request(spec) } @@ -45,7 +45,7 @@ extension CommentAPIWorker { ) -> Observable { let spec = CommentAPIs.createPostComment(postId: postId, body: body).spec - return request(spec, of: PostCommentResponseDTO.self) + return request(spec) } @@ -58,7 +58,7 @@ extension CommentAPIWorker { ) -> Observable { let spec = CommentAPIs.updatePostComment(postId: postId, commentId: commentId).spec - return request(spec, of: PostCommentResponseDTO.self) + return request(spec) } @@ -70,7 +70,7 @@ extension CommentAPIWorker { ) -> Observable { let spec = CommentAPIs.deletePostComment(postId: postId, commentId: commentId).spec - return request(spec, of: PostCommentDeleteResponseDTO.self) + return request(spec) } } diff --git a/14th-team5-iOS/Data/Sources/APIs/BBAPISpec.swift b/14th-team5-iOS/Data/Sources/BBNetwork/BBAPISpec.swift similarity index 62% rename from 14th-team5-iOS/Data/Sources/APIs/BBAPISpec.swift rename to 14th-team5-iOS/Data/Sources/BBNetwork/BBAPISpec.swift index fdbc8473b..dc533ccbe 100644 --- a/14th-team5-iOS/Data/Sources/APIs/BBAPISpec.swift +++ b/14th-team5-iOS/Data/Sources/BBNetwork/BBAPISpec.swift @@ -5,101 +5,74 @@ // Created by 김건우 on 9/25/24. // +import Core import Foundation -/// API 요청에 필요한 재료 묶음인 Spec입니다. -/// -/// 호출 메소드, 호출 URL, 요청 바디와 헤더가 포함되어 있습니다. -/// -/// - Authors: 김소월 -public struct BBAPISpec { - - /// 호출 메서드입니다. - public let method: BBAPIMethod - - /// 호출하고자 하는 API의 URL 경로입니다. - /// - /// 베이스 URL을 제외한 나머지 URL만 작성해야 합니다. 예를 들어, 전체 URL이 **https://api.oing.kr/v1/families/{familyId}/name**라면 베이스 URL을 제외한 **/families/{familyId}/name**만 작성해야 합니다. - public let path: String - - /// 호출하고자 하는 API의 쿼리 파라미터입니다. - /// - /// `path` 프로퍼티에서 모든 쿼리 파라미터를 포함시킬 수 있지만, 더욱 깔끔하게 Spec을 작성하려면 해당 프로퍼티를 작성하세요. - /// 자주 사용되는 쿼리 파라미터 키와 값은 미리 정의되어 있습니다. 별도 키와 값을 사용하고 싶다면, 문자열을 입력하세요. - /// - /// - Warning: `path` 프로퍼티에 쿼리 파라미터를 함께 적어주었다면 해당 프로퍼티에 값을 전달하면 안됩니다. - /// - public let parameters: BBParameters? - - /// 요청 바디입니다. Encodable 프로토콜을 준수하는 객체여야 합니다. - public let requestBody: (any Encodable)? - - /// 요청 헤더입니다. 기본값으로 X-App-Key, X-Auth-Token, X-UserId, X-User-Platform이 포함되어 있습니다. - public let headers: BBAPIHeaders - - /// API 요청에 필요한 재료 묶음인 Spec을 만듭니다. - /// - Parameters: - /// - method: 호출 메서드 - /// - url: 호출 URL (베이스 URL 제외) - /// - requestBody: 요청 바디 - /// - headers: 요청 헤더 - public init( - method: BBAPIMethod, - path: String, - parameters: BBParameters? = nil, - requestBody: (any Encodable)? = nil, - headers: BBAPIHeaders = BBAPIHeader.default - ) { - self.method = method - self.path = path - self.parameters = parameters - self.requestBody = requestBody - self.headers = headers - } - + +// MARK: - BBAPI + +public protocol BBAPI { + var spec: BBAPISpec { get } } -// MARK: - Extension +// MARK: - APISpecable -public extension BBAPISpec { +public protocol APISpecable { + var method: BBNetworkMethod { get } + var path: String { get } + var parameters: BBNetworkParameters? { get } + var requestBody: (any Encodable)? { get } + var headers: BBNetworkHeaders { get } + + func urlRequest(_ config: any NetworkConfigurable) -> URLRequest +} + +extension APISpecable { + + /// 주어진 Spec을 바탕으로 URLRequeset를 생성합니다. + /// - Parameter config: 네트워크 설정값 + /// - Returns: URLRequest + public func urlRequest(_ config: any NetworkConfigurable = BBNetworkDefaultConfiguration()) -> URLRequest { + var urlRequest = URLRequest(url: url(config)) + urlRequest.httpMethod = method.asHTTPMethod.rawValue + urlRequest.headers = headers.asHTTPHeaders + urlRequest.httpBody = requestBody?.encodeToData() + urlRequest.timeoutInterval = config.timeoutInterval + return urlRequest + } /// 정제된 URL 문자열을 반환합니다. - var urlString: String { + private func url(_ config: any NetworkConfigurable) -> URL { var urlString: String = path - urlString = normalizeUrl(urlString) - return urlString + urlString = normalizeUrl(urlString, config) + return URL(string: urlString)! } } -private extension BBAPISpec { - - // 기초 URL을 반환합니다. - var _baseURL: String { - return BBAPIConfiguration.baseUrl - } +extension APISpecable { /// `path` 프로퍼티로 주어진 URL을 바탕으로 베이스 URL을 추가하고, 유효한 URL인지 검사합니다. - func normalizeUrl(_ dirtyUrlString: String) -> String { + private func normalizeUrl(_ dirtyUrlString: String, _ config: any NetworkConfigurable) -> String { var urlString = dirtyUrlString - urlString = prependBaseUrl(urlString) + urlString = prependBaseUrl(urlString, config) urlString = sanitizeUrlWithRegex(urlString) urlString = appendQueryParamter(urlString) return urlString } /// `path` 프로퍼티 앞에 베이스 URL을 추가합니다. - func prependBaseUrl(_ dirtyUrlString: String) -> String { + private func prependBaseUrl(_ dirtyUrlString: String, _ config: any NetworkConfigurable) -> String { var urlString = dirtyUrlString - if !dirtyUrlString.hasPrefix(_baseURL) { - urlString = _baseURL + path + if !dirtyUrlString.hasPrefix(config.baseUrl) { + urlString = config.baseUrl + path } return urlString } /// 주어진 URL이 유효한 URL인지 검사하고 수정합니다. - func sanitizeUrlWithRegex(_ dirtyUrlString: String) -> String { + private func sanitizeUrlWithRegex(_ dirtyUrlString: String) -> String { var urlString = dirtyUrlString urlString = replaceRegex(":/{3,}", "://", urlString) urlString = replaceRegex("(? String { + private func appendQueryParamter(_ dirtyUrlString: String) -> String { var urlString = dirtyUrlString if let parameters = parameters { urlString += "?" - let queries: [String] = parameters.map { key, value in - "\(key.rawValue)=\(value.rawValue)" - } - urlString += queries.joined(separator: "&") + let queries = parameters.toQueryParameters() + urlString += queries } return urlString } - func replaceRegex( + private func replaceRegex( _ pattern: String, _ replacement: String, _ string: String @@ -133,3 +104,59 @@ private extension BBAPISpec { } } + + +// MARK: - BBAPISpec + + +/// API 요청에 필요한 재료 묶음인 Spec입니다. +/// +/// 호출 메소드, 호출 URL, 요청 바디와 헤더가 포함되어 있습니다. +/// +/// - Authors: 김소월 +public struct BBAPISpec: APISpecable { + + /// 호출 메서드입니다. + public let method: BBNetworkMethod + + /// 호출하고자 하는 API의 URL 경로입니다. + /// + /// 베이스 URL을 제외한 나머지 URL만 작성해야 합니다. 예를 들어, 전체 URL이 **https://api.oing.kr/v1/families/{familyId}/name**라면 베이스 URL을 제외한 **/families/{familyId}/name**만 작성해야 합니다. + public let path: String + + /// 호출하고자 하는 API의 쿼리 파라미터입니다. + /// + /// `path` 프로퍼티에서 모든 쿼리 파라미터를 포함시킬 수 있지만, 더욱 깔끔하게 Spec을 작성하려면 해당 프로퍼티를 작성하세요. + /// 자주 사용되는 쿼리 파라미터 키와 값은 미리 정의되어 있습니다. 별도 키와 값을 사용하고 싶다면, 문자열을 입력하세요. + /// + /// - Warning: `path` 프로퍼티에 쿼리 파라미터를 함께 적어주었다면 해당 프로퍼티에 값을 전달하면 안됩니다. + /// + public let parameters: BBNetworkParameters? + + /// 요청 바디입니다. Encodable 프로토콜을 준수하는 객체여야 합니다. + public let requestBody: (any Encodable)? + + /// 요청 헤더입니다. 기본값으로 X-App-Key, X-Auth-Token, X-UserId, X-User-Platform이 포함되어 있습니다. + public let headers: BBNetworkHeaders + + /// API 요청에 필요한 재료 묶음인 Spec을 만듭니다. + /// - Parameters: + /// - method: 호출 메서드 + /// - url: 호출 URL (베이스 URL 제외) + /// - requestBody: 요청 바디 + /// - headers: 요청 헤더 + public init( + method: BBNetworkMethod, + path: String, + parameters: BBNetworkParameters? = nil, + requestBody: (any Encodable)? = nil, + headers: BBNetworkHeaders = BBNetworkHeader.default + ) { + self.method = method + self.path = path + self.parameters = parameters + self.requestBody = requestBody + self.headers = headers + } + +} diff --git a/14th-team5-iOS/Data/Sources/BBNetwork/BBAPIWorker.swift b/14th-team5-iOS/Data/Sources/BBNetwork/BBAPIWorker.swift new file mode 100644 index 000000000..bf462840f --- /dev/null +++ b/14th-team5-iOS/Data/Sources/BBNetwork/BBAPIWorker.swift @@ -0,0 +1,264 @@ +// +// BBAPIWorker.swift +// BBNetwork +// +// Created by 김건우 on 9/25/24. +// + +import Core +import Foundation + +import Alamofire +import RxAlamofire +import RxCocoa +import RxSwift + + +// MARK: - Worker Error + +/// 디코딩 및 네트워크 통신 중 발생하는 예외입니다. +public enum WorkerError: Error { + + /// 파싱 중 에러가 발생했음을 나타냅니다. + case parsing(Error) + + /// 네트워크 통신 중 문제가 발생했음을 나타냅니다. + case networkFailure(BBNetworkError) + +} + + +// MARK: - Workable + +protocol Workable { + @discardableResult + func request( + _ spec: any APISpecable, + on queue: any SchedulerType, + using decoder: any ResponseDecoder, + config: any NetworkConfigurable + ) -> Observable where D: Decodable + + @discardableResult + func request( + _ spec: any APISpecable, + config: any NetworkConfigurable + ) -> Observable where D: Decodable +} + + +// MARK: - API Worker + +class BBAPIWorker: Workable { + + /// 주어진 Spec을 토대로 HTTP 통신을 수행합니다. + /// + /// HTTP 통신에 성공한다면 next 항목을 방출하고, 실패한다면 error 항목을 방출합니다. + /// + /// 해당 메서드가 반환되는 시점에서 스트림이 queue 매개변수로 주어진 쓰레드로 바뀌며 `observe(on:)` 연산자를 호출할 필요가 없습니다. + /// + /// - Parameters: + /// - spec: BBAPISepc 타입 객체 + /// - queue: 스트림 쓰레드 + /// - decoder: JSONDecoder 객체 + /// - config: 네트워크 설정값 + /// - Returns: Observable\ + func request( + _ spec: any APISpecable, + on queue: any SchedulerType = RxScheduler.main, + using decoder: any ResponseDecoder = BBDefaultResponderDecoder(), + config: any NetworkConfigurable = BBNetworkDefaultConfiguration() + ) -> Observable where D: Decodable { + let urlRequest = spec.urlRequest(config) + + return request(urlRequest, on: queue, using: decoder, config: config) + } + + /// 주어진 Spec을 토대로 HTTP 통신을 수행합니다. + /// + /// HTTP 통신에 성공한다면 next 항목을 방출하고, 실패한다면 error 항목을 방출합니다. + /// + /// 해당 메서드가 반환되는 시점에서 스트림이 메인 쓰레드로 바뀌며 `observe(on:)` 연산자를 호출할 필요가 없습니다. + /// + /// - Parameters: + /// - spec: BBAPISepc 타입 객체 + /// - config: 네트워크 설정값 + /// - Returns: Observable\ + func request( + _ spec: any APISpecable, + config: any NetworkConfigurable + ) -> RxSwift.Observable where D: Decodable { + let urlRequest = spec.urlRequest(config) + let queue = RxScheduler.main + let decoder = BBDefaultResponderDecoder() + + return request(urlRequest, on: queue, using: decoder, config: config) + } + + + private func request( + _ urlRequest: any URLRequestConvertible, + on queue: any SchedulerType, + using decoder: any ResponseDecoder, + config: any NetworkConfigurable + ) -> Observable where D: Decodable { + let `default` = config.session + + return `default`.session.rx.request(urlRequest: urlRequest) + .responseData() + .observe(on: queue) + .tryFilterSuccessfulStatusCode() + .tryDecode(using: decoder) + .resolveError() + } + +} + + + +// MARK: - Extensions + +private extension ObservableType where Element == (HTTPURLResponse, Data) { + + /// 상태 코드가 올바른지 검사합니다. + /// + /// 상태 코드가 `range` 매개변수로 주어진 범위 내에 있다면 next 항목을 방출하고, 그렇지 않다면 error 항목을 방출합니다. + /// - Parameter range: 정상 상태 코드 범위 + /// - Returns: Observable\ + /// + /// - Authors: 김소월 + func tryFilterSuccessfulStatusCode(statusCode range: Range = 200..<300) -> Observable { + flatMap { element -> Observable in + let data = element.1 + let statusCode = element.0.statusCode + + return Observable.create { observer in + if statusCode == 204 { + observer.onError(BBNetworkError.noContent) + observer.onCompleted() + } + else if range ~= statusCode { + observer.onNext(data) + observer.onCompleted() + } else { + observer.onError(BBNetworkError.resolve(statusCode)) + } + + return Disposables.create() + } + } + } + +} + + +private extension ObservableType where Element == Data { + + /// next 항목을 디코딩합니다. + /// + /// 디코딩에 성공하면 next 항목을 방출하고, 그렇지 않다면 error 항목을 방출합니다. + /// - Parameters: + /// - type: 디코딩하고자 하는 타입 + /// - decoder: JSONDecoder 객체 + /// - Returns: Observable\ + /// + /// - Authors: 김소월 + func tryDecode( + using decoder: any ResponseDecoder = BBDefaultResponderDecoder() + ) -> Observable { + flatMap { data -> Observable in + Observable.create { observer in + do { + let decodedData: T = try decoder.decode(from: data) + observer.onNext(decodedData) + } catch { + observer.onError(WorkerError.parsing(error)) + } + return Disposables.create() + } + } + } + +} + +private extension ObservableType { + + /// BBNetworkError를 WorkerError로 치환합니다. + func resolveError() -> Observable { + return `catch` { error in + if let error = error as? BBNetworkError { + return Observable.error(WorkerError.networkFailure(error)) + } + return Observable.error(error) + } + } + +} + + +public extension ObservableType { + + /// API 통신 중 발생한 WorkerError 예외를 처리하세요. + /// + /// erorr 항목이 WorkerError가 아니면 해당 연산자는 무시됩니다. + /// + /// - Parameter handler: 예외 처리 클로저 + /// - Returns: Observable\ + /// - Authors: 김소월 + func catchWorkerError(_ handler: @escaping ((WorkerError) -> Observable)) -> Observable { + return `catch` { error in + if let error = error as? WorkerError { + return handler(error) + } + return Observable.error(error) + } + } + + /// API 통신 중 발생한 WorkerError 예외를 처리하세요. + /// + /// erorr 항목이 WorkerError가 아니면 해당 연산자는 무시됩니다. + /// + /// - Parameters: + /// - object: 약하게 참조하고자 하는 객체 + /// - handler: 예외 처리 클로저 + /// + /// - Returns: Observable\ + /// - Authors: 김소월 + func catchWorkerError( + with object: O, + _ handler: @escaping ((O, WorkerError) -> Observable) + ) -> Observable where O: AnyObject { + return `catch` { [weak object] error in + guard let object else { return Observable.error(error) } + if let error = error as? WorkerError { + return handler(object, error) + } + return Observable.error(error) + } + } + + /// 데이터베이스 접근 중 발생한 StorageError 예외를 처리하세요. + /// + /// erorr 항목이 StorageError가 아니면 해당 연산자는 무시됩니다. + /// + /// - Parameter handler: 예외 처리 클로저 + /// - Returns: Observable\ + /// - Authors: 김소월 + //func catchStorageError(_ handler: @escaping ((StorageError) -> Observable)) -> Observable { + // return `catch` { error in + // if let error = error as? StorageError { + // return handler(error) + // } + // return Observable.error(error) + // } + //} + + /// error 항목을 받으면 completed 항목을 방출하고 스트림을 종료합니다. + /// - Authors: 김소월 + func catchErrorJustComplete() -> Observable { + return `catch` { _ in + Observable.empty() + } + } + +} diff --git a/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkConfiguration.swift b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkConfiguration.swift new file mode 100644 index 000000000..a1b125023 --- /dev/null +++ b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkConfiguration.swift @@ -0,0 +1,45 @@ +// +// BBAPIConfiguration.swift +// BBNetwork +// +// Created by 김건우 on 9/25/24. +// + +import Foundation + + +// MARK: - APIConfigrable + +public protocol NetworkConfigurable { + var session: any BBNetworkSession { get } + var timeoutInterval: TimeInterval { get } + var baseUrl: String { get } +} + + +// MARK: - DefaultConfiguration + +/// BBAPI의 설정값입니다. +public struct BBNetworkDefaultConfiguration: NetworkConfigurable { + + public let session: any BBNetworkSession + public let timeoutInterval: TimeInterval + + /// 기초 URL을 반환합니다. 빌드 환경에 따라 반환되는 URL이 달라집니다. + public var baseUrl: String = { + #if PRD + return "https://api.no5ing.kr/v1" + #else + return "https://dev.api.no5ing.kr/v1" + #endif + }() + + public init( + session: any BBNetworkSession = BBDefaultNetworkSession(), + timeoutInterval: TimeInterval = 10 + ) { + self.session = session + self.timeoutInterval = timeoutInterval + } + +} diff --git a/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkError.swift b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkError.swift new file mode 100644 index 000000000..d0bef5f57 --- /dev/null +++ b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkError.swift @@ -0,0 +1,56 @@ +// +// BBNetworkError.swift +// Data +// +// Created by 김건우 on 10/2/24. +// + +import Foundation + +// MARK: - Network Error + +/// 네트워크 통신 중 발생하는 예외입니다. +public enum BBNetworkError: Error { + + /// 받아온 데이터가 없음을 나타냅니다. (204 에러) + case noContent + + /// 잘못된 요청을 보냈음을 나타냅니다. 요청 구문, 유효성 검사 실패 또는 잘못된 파라미터로 인해 서버가 요청을 이해할 수 없을 때 발생합니다. (400 에러) + case badRequest + + /// 인증되지 않은 사용자가 요청을 시도할 때 발생합니다. 일반적으로 로그인이 필요한 API 요청에 사용됩니다. 토큰이 없거나 잘못된 경우에도 발생할 수 있습니다. (401 에러) + case unauthorized + + /// 서버가 요청을 이해했지만 권한이 없어 해당 요청을 수행할 수 없을 때 발생합니다. 사용자는 이 리소스에 액세스할 권한이 없습니다. (403 에러) + case forbidden + + /// 요청한 리소스가 존재하지 않음을 나타냅니다. URL이 잘못되었거나, 리소스가 삭제된 경우 발생합니다. (404 에러) + case notFound + + /// 서버에서 처리 중에 예상치 못한 오류가 발생했음을 의미합니다. 개발자가 서버 측에서 문제를 디버깅해야 합니다. (500 에러) + case internalServerError + + /// 서버가 현재 요청을 처리할 수 없는 상태입니다. 서버가 과부하 상태이거나 유지보수 중일 때 발생할 수 있습니다. (503 에러) + case serviceUnavailable + + /// 이 밖에 알 수 없는 오류가 발생했음을 의미합니다. 상태 코드를 확인해 직접 원인을 파악해야 합니다. + case error(statusCode: Int) + +} + +extension BBNetworkError { + + /// HTTP 상태 코드를 기반으로 BBNetworkError를 반환하는 메서드입니다. + static func resolve(_ statusCode: Int) -> Self { + switch statusCode { + case 204: return .noContent + case 400: return .badRequest + case 401: return .unauthorized + case 403: return .forbidden + case 404: return .notFound + case 500: return .internalServerError + case 503: return .serviceUnavailable + default: return .error(statusCode: statusCode) + } + } +} diff --git a/14th-team5-iOS/Data/Sources/APIs/BBEventMonitor.swift b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkEventMonitor.swift similarity index 91% rename from 14th-team5-iOS/Data/Sources/APIs/BBEventMonitor.swift rename to 14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkEventMonitor.swift index 9c165a897..711326a37 100644 --- a/14th-team5-iOS/Data/Sources/APIs/BBEventMonitor.swift +++ b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkEventMonitor.swift @@ -9,7 +9,10 @@ import Foundation import Alamofire -final class BBEventMonitor: EventMonitor { + +// MARK: - Default Event Monitor + +final class BBNetworkEventMonitor: EventMonitor { func requestDidFinish(_ request: Request) { print("[Reqeust BibbiNetwork LOG]") diff --git a/14th-team5-iOS/Data/Sources/APIs/BBAPIHeader.swift b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkHeader.swift similarity index 65% rename from 14th-team5-iOS/Data/Sources/APIs/BBAPIHeader.swift rename to 14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkHeader.swift index c02ad3975..9cab7c383 100644 --- a/14th-team5-iOS/Data/Sources/APIs/BBAPIHeader.swift +++ b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkHeader.swift @@ -1,5 +1,5 @@ // -// BBAPIHeader.swift +// BBNetworkHeader.swift // BBNetwork // // Created by 김건우 on 9/25/24. @@ -12,39 +12,40 @@ import Alamofire // MARK: - Typealias -public typealias BBAPIHeaders = [BBAPIHeader] +public typealias BBNetworkHeaders = [BBNetworkHeader] // MARK: - Header /// 서버에 전달하는 부가적인 정보입니다. -public enum BBAPIHeader { +public enum BBNetworkHeader { case xAppKey case xAuthToken case xUserPlatform case xUserId + case contentType } // MARK: - Extensions -public extension BBAPIHeader { +public extension BBNetworkHeader { /// 가장 일반적인 헤더 모음입니다. - static var `default`: [BBAPIHeader] { - [.xAppKey, .xAuthToken, .xUserPlatform, .xUserId] + static var `default`: [BBNetworkHeader] { + [.xAppKey, .xAuthToken, .xUserPlatform, .xUserId, .contentType] } /// 인증이 필요없는 API 요청에 사용되는 헤더 모음입니다. - static var unAuthorized: [BBAPIHeader] { - [.xAppKey, .xUserPlatform] + static var unAuthorized: [BBNetworkHeader] { + [.xAppKey, .xUserPlatform, .contentType] } } -public extension BBAPIHeader { +public extension BBNetworkHeader { /// 헤더의 키입니다. var key: String { @@ -53,29 +54,31 @@ public extension BBAPIHeader { case .xAuthToken: return "X-AUTH-TOKEN" case .xUserPlatform: return "X-USER-PLATFORM" case .xUserId: return "X-USER-ID" + case .contentType: return "Content-Type" } } /// 헤더가 가지는 실질적인 값입니다. var value: String { switch self { - case .xAppKey: return fetchXppKey() + case .xAppKey: return fetchXAppKey() case .xAuthToken: return fetchXAuthTokenValue() case .xUserPlatform: return fetchXUserPlatform() case .xUserId: return fetchXuserId() + case .contentType: return fetchContentType() } } - /// `BBAPIHeader`를 Alamofire의 `HTTPHeader` 타입으로 변환합니다. + /// `BBNetworkHeader`를 Alamofire의 `HTTPHeader` 타입으로 변환합니다. var asHTTPHeader: HTTPHeader { HTTPHeader(name: key, value: value) } } -private extension BBAPIHeader { +private extension BBNetworkHeader { - func fetchXppKey() -> String { + func fetchXAppKey() -> String { // TODO: - 코드 리팩토링하기 return "7c5aaa36-570e-491f-b18a-26a1a0b72959" } @@ -85,7 +88,7 @@ private extension BBAPIHeader { guard let data: Data = KeychainWrapper.standard.string(forKey: .accessToken)?.data(using: .utf8), let tokenResult: AccessToken = try? JSONDecoder().decode(AccessToken.self, from: data) - else { fatalError("🔴 Error: 액세스 토큰을 가져올 수 없습니다.") } + else { return "" /* 예외 코드 작성 */ } return tokenResult.accessToken! } @@ -98,16 +101,20 @@ private extension BBAPIHeader { // TODO: - 코드 리팩토링하기 guard let memberId: String = UserDefaultsWrapper.standard.string(forKey: .memberId) - else { fatalError("🔴 Error: 유저 ID를 가져올 수 없습니다.") } + else { return "" /* 예외 코드 작성 */ } return memberId } + func fetchContentType() -> String { + return "application/json" + } + } -public extension Array where Element == BBAPIHeader { +public extension Array where Element == BBNetworkHeader { - /// `[BBAPIHeader]`를 Alamofire의 `HTTPHeaders` 타입으로 변환합니다. + /// `[BBNetworkHeader]`를 Alamofire의 `HTTPHeaders` 타입으로 변환합니다. var asHTTPHeaders: HTTPHeaders { HTTPHeaders(self.map { $0.asHTTPHeader }) } diff --git a/14th-team5-iOS/Data/Sources/APIs/BBIntercepter.swift b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkInterceptor.swift similarity index 94% rename from 14th-team5-iOS/Data/Sources/APIs/BBIntercepter.swift rename to 14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkInterceptor.swift index fb28d85d2..d9cdafef0 100644 --- a/14th-team5-iOS/Data/Sources/APIs/BBIntercepter.swift +++ b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkInterceptor.swift @@ -10,15 +10,15 @@ import Foundation import Alamofire import RxSwift -final class BBIntercepter: Interceptor { + +// MARK: - Default Interceptor + +final class BBNetworkInterceptor: Interceptor { // MARK: - Properties private let disposeBag = DisposeBag() - private var retryCount = 0 - private var retryLimit = 3 - private let tokenKeychain = TokenKeychain() private let oAuthAPIWorker = OAuthAPIWorker() diff --git a/14th-team5-iOS/Data/Sources/APIs/BBAPIMethod.swift b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkMethod.swift similarity index 91% rename from 14th-team5-iOS/Data/Sources/APIs/BBAPIMethod.swift rename to 14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkMethod.swift index 957af2515..5827cd687 100644 --- a/14th-team5-iOS/Data/Sources/APIs/BBAPIMethod.swift +++ b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkMethod.swift @@ -10,7 +10,7 @@ import Foundation import Alamofire /// 서버가 수행해야 할 동작입니다. -public enum BBAPIMethod { +public enum BBNetworkMethod { /// 데이터 조회 case get @@ -26,7 +26,7 @@ public enum BBAPIMethod { } -public extension BBAPIMethod { +public extension BBNetworkMethod { /// `BBAPIMethod`를 Alamofire의 `HTTPMethod` 타입으로 변환합니다. var asHTTPMethod: HTTPMethod { diff --git a/14th-team5-iOS/Data/Sources/APIs/BBParameter.swift b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkParameter.swift similarity index 81% rename from 14th-team5-iOS/Data/Sources/APIs/BBParameter.swift rename to 14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkParameter.swift index 5be1bdc3e..de9008b4b 100644 --- a/14th-team5-iOS/Data/Sources/APIs/BBParameter.swift +++ b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkParameter.swift @@ -9,14 +9,14 @@ import Foundation // MARK: - Typealias -public typealias BBParameters = [BBParameter.Key: BBParameter.Value] -public typealias BBParameterKey = BBParameter.Key -public typealias BBParameterValue = BBParameter.Value +public typealias BBNetworkParameters = [BBNetworkParameter.Key: BBNetworkParameter.Value] +public typealias BBNetworkParameterKey = BBNetworkParameter.Key +public typealias BBNetworkParameterValue = BBNetworkParameter.Value // MARK: - BBParameter -public struct BBParameter { +public struct BBNetworkParameter { // MARK: - Key @@ -62,11 +62,11 @@ public struct BBParameter { // MARK: - Extensions -extension BBParameterKey: Hashable { } +extension BBNetworkParameterKey: Hashable { } -extension BBParameterKey { +extension BBNetworkParameterKey { static var page: Self = "page" static var size: Self = "size" @@ -78,7 +78,7 @@ extension BBParameterKey { } -extension BBParameterValue { +extension BBNetworkParameterValue { static var asc: Self = "ASC" static var desc: Self = "DESC" diff --git a/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkSession.swift b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkSession.swift new file mode 100644 index 000000000..b48cb27b6 --- /dev/null +++ b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBNetworkSession.swift @@ -0,0 +1,32 @@ +// +// File.swift +// BBNetwork +// +// Created by 김건우 on 9/25/24. +// + +import Foundation + +import Alamofire + +// MARK: - SessionProvider + +public protocol BBNetworkSession { + var session: Session { get } +} + + +// MARK: - BBDefaultSession + +public struct BBDefaultNetworkSession: BBNetworkSession { + + public let session: Session = { + let eventMonitor = BBNetworkEventMonitor() + let interceptor = BBNetworkInterceptor() + let session = Session(interceptor: interceptor, eventMonitors: [eventMonitor]) + return session + }() + + public init() { } + +} diff --git a/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBResponseDecoder.swift b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBResponseDecoder.swift new file mode 100644 index 000000000..9b968402c --- /dev/null +++ b/14th-team5-iOS/Data/Sources/BBNetwork/Network/BBResponseDecoder.swift @@ -0,0 +1,40 @@ +// +// BBResponseDecoder.swift +// Data +// +// Created by 김건우 on 10/2/24. +// + +import Foundation + +// MARK: - Response Decoder + +public protocol ResponseDecoder { + func decode(from data: Data) throws -> T +} + + +// MARK: - JSON Default Response Decoder + +public struct BBDefaultResponderDecoder: ResponseDecoder { + private let decoder = JSONDecoder() + public init() { } + public func decode(from data: Data) throws -> T where T : Decodable { + return try decoder.decode(T.self, from: data) + } +} + + +// MARK: - JSON Iso8601 Response Decoder + +public struct BBIso8601ResponderDecoder: ResponseDecoder { + private let decoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + public init() { } + public func decode(from data: Data) throws -> T where T : Decodable { + return try decoder().decode(T.self, from: data) + } +}