diff --git a/Projects/Core/APIClient/Interface/Model/TargetTypeInterface.swift b/Projects/Core/APIClient/Interface/APIBaseRequestInterface.swift similarity index 85% rename from Projects/Core/APIClient/Interface/Model/TargetTypeInterface.swift rename to Projects/Core/APIClient/Interface/APIBaseRequestInterface.swift index a57e5da..469a2f0 100644 --- a/Projects/Core/APIClient/Interface/Model/TargetTypeInterface.swift +++ b/Projects/Core/APIClient/Interface/APIBaseRequestInterface.swift @@ -10,7 +10,7 @@ import Foundation import Dependencies -public protocol TargetType { +public protocol APIBaseRequest { var baseURL: String { get } var path: String { get } var method: HTTPMethod { get } @@ -20,8 +20,10 @@ public protocol TargetType { public enum API { public static var apiBaseURL: String { - guard let baseURL = Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String else { fatalError("url missing") } - return "https://" + baseURL + guard let baseURL = Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String else { + fatalError("url missing") + } + return baseURL } } @@ -42,7 +44,7 @@ public enum RequestParams { case body(_ parameter: Encodable) } -public extension TargetType { +public extension APIBaseRequest { var contentType: ContentType { return .json } diff --git a/Projects/Core/APIClient/Interface/APIClientInterface.swift b/Projects/Core/APIClient/Interface/APIClientInterface.swift new file mode 100644 index 0000000..d9fa7cc --- /dev/null +++ b/Projects/Core/APIClient/Interface/APIClientInterface.swift @@ -0,0 +1,42 @@ +// +// APIClientInterface.swift +// APIClient +// +// Created by 김지현 on 8/6/24. +// Copyright © 2024 PomoNyang. All rights reserved. +// + +import Foundation + +import Logger + +import Dependencies +import DependenciesMacros + +@DependencyClient +public struct APIClient { + public var apiRequest: @Sendable ( + _ request: APIBaseRequest, + _ isWithInterceptor: Bool + ) async throws -> (Data, URLResponse) + + public func apiRequest( + request: APIBaseRequest, + as: T.Type, + isWithInterceptor: Bool = true + ) async throws -> T { + let (data, _) = try await self.apiRequest(request, isWithInterceptor) + + do { + let decodedData = try JSONDecoder().decode(T.self, from: data) + return decodedData + } catch { + throw NetworkError.decodingError + } + } +} + +extension APIClient: TestDependencyKey { + public static let previewValue = Self() + public static let testValue = Self() +} diff --git a/Projects/Core/APIClient/Interface/Model/NetworkError.swift b/Projects/Core/APIClient/Interface/Model/NetworkError.swift index 7382bae..26fdeac 100644 --- a/Projects/Core/APIClient/Interface/Model/NetworkError.swift +++ b/Projects/Core/APIClient/Interface/Model/NetworkError.swift @@ -9,33 +9,39 @@ import Foundation public enum NetworkError: Error { - case requestError(_ description: String) - case noResponseError - case authorizationError - case decodingError - case serverError - case networkConnectionError - case timeOutError - case unknownError + case requestError(_ description: String) + case apiError(_ description: String) + case noResponseError + case authorizationError + case decodingError + case serverError + case networkConnectionError + case timeOutError + case unknownError + case refreshTokenExpired - var errorMessage: String { - switch self { - case let .requestError(description): - return "Request Error: \(description)" - case .decodingError: - return "Decoding Error" - case .serverError: - return "Server Error" - case .networkConnectionError: - return "Network Connection Error" - case .timeOutError: - return "Timeout Error" - case .unknownError: - return "Unknown Error" - case .noResponseError: - return "No Response Error" - case .authorizationError: - return "Autorization Error" - } + public var errorMessage: String { + switch self { + case let .requestError(description): + return "Request Error: \(description)" + case let .apiError(description): + return "API Error: \(description)" + case .decodingError: + return "Decoding Error" + case .serverError: + return "Server Error" + case .networkConnectionError: + return "Network Connection Error" + case .timeOutError: + return "Timeout Error" + case .unknownError: + return "Unknown Error" + case .noResponseError: + return "No Response Error" + case .authorizationError: + return "Autorization Error" + case .refreshTokenExpired: + return "Refresh Token has been expired Error" } + } } diff --git a/Projects/Core/APIClient/Interface/TokenInterceptorInterface.swift b/Projects/Core/APIClient/Interface/TokenInterceptorInterface.swift deleted file mode 100644 index 798d2ab..0000000 --- a/Projects/Core/APIClient/Interface/TokenInterceptorInterface.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// AccessTokenInterceptorInterface.swift -// APIClient -// -// Created by 김지현 on 7/29/24. -// Copyright © 2024 PomoNyang. All rights reserved. -// - -import Foundation -import KeychainClientInterface - -public protocol TokenInterceptorInterface { - var session: URLSession { get } - var keychainClient: KeychainClient { get } - func adapt(_ request: URLRequest) async throws -> URLRequest - func retry(_ request: URLRequest, dueTo error: Error) async -> Bool -} diff --git a/Projects/Core/APIClient/Sources/TargetType.swift b/Projects/Core/APIClient/Sources/APIBaseRequest.swift similarity index 71% rename from Projects/Core/APIClient/Sources/TargetType.swift rename to Projects/Core/APIClient/Sources/APIBaseRequest.swift index df0e47c..53db765 100644 --- a/Projects/Core/APIClient/Sources/TargetType.swift +++ b/Projects/Core/APIClient/Sources/APIBaseRequest.swift @@ -8,31 +8,23 @@ import Foundation import APIClientInterface -import KeychainClientInterface import Shared - -extension TargetType { - func asURLRequest(keychainClient: KeychainClient) async throws -> URLRequest { - let url = URL(string: baseURL)! - var urlRequest = URLRequest(url: url.appendingPathComponent(path)) +extension APIBaseRequest { + func asURLRequest() async throws -> URLRequest { + let baseURL = URL(string: "https://\(self.baseURL)")! + var urlRequest = URLRequest(url: baseURL.appendingPathComponent(path)) urlRequest.httpMethod = method.rawValue urlRequest.setValue( contentType.rawValue, forHTTPHeaderField: HTTPHeaderField.contentType.rawValue ) - if let jwt = keychainClient.read(key: "jwt") { - urlRequest.addValue( - jwt, - forHTTPHeaderField: HTTPHeaderField.authentication.rawValue - ) - } switch parameters { case .query(let request): let params = try request.toDictionary() let queryParams = params.map { URLQueryItem(name: $0.key, value: "\($0.value)") } - var components = URLComponents(string: url.appendingPathComponent(path).absoluteString) + var components = URLComponents(string: baseURL.appendingPathComponent(path).absoluteString) components?.queryItems = queryParams urlRequest.url = components?.url @@ -57,7 +49,7 @@ extension TargetType { } } -public extension TargetType { +public extension APIBaseRequest { var contentType: ContentType { return .json } diff --git a/Projects/Core/APIClient/Sources/APIClient.swift b/Projects/Core/APIClient/Sources/APIClient.swift new file mode 100644 index 0000000..7d0c8ed --- /dev/null +++ b/Projects/Core/APIClient/Sources/APIClient.swift @@ -0,0 +1,86 @@ +// +// APIClient.swift +// APIClient +// +// Created by 김지현 on 8/6/24. +// Copyright © 2024 PomoNyang. All rights reserved. +// + +import Foundation + +import Logger +import APIClientInterface + +import Dependencies + +extension APIClient: DependencyKey { + public static let liveValue: APIClient = .live() + + public static func live() -> Self { + + actor Session { + nonisolated let tokenInterceptor: TokenInterceptor + + let session: URLSession = { + let config = URLSessionConfiguration.default + return URLSession(configuration: config) + }() + + let decoder: JSONDecoder = JSONDecoder() + + init(tokenInterceptor: TokenInterceptor) { + self.tokenInterceptor = tokenInterceptor + } + + func sendRequest( + _ request: APIBaseRequest, + isWithInterceptor: Bool, + retryCnt: Int = 0 + ) async throws -> (Data, URLResponse) { + guard retryCnt < 3 else { throw throwNetworkErr(.timeOutError) } + + var urlRequest = try await request.asURLRequest() + if isWithInterceptor { + urlRequest = try await tokenInterceptor.adapt(urlRequest) + } + Logger.shared.log(category: .network, "API Request:\n\(dump(urlRequest))") + + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + let error = NetworkError.noResponseError + Logger.shared.log(level: .error, category: .network, "API Error:\n\(dump(error))") + throw error + } + + switch httpResponse.statusCode { + case 200..<300: + return (data, response) + case 401: + try await tokenInterceptor.retry(for: self.session) + return try await sendRequest(request, isWithInterceptor: isWithInterceptor, retryCnt: retryCnt + 1) + case 400..<500: + throw throwNetworkErr(.requestError("bad request")) + case 500..<600: + throw throwNetworkErr(.serverError) + default: + throw throwNetworkErr(.unknownError) + } + + func throwNetworkErr(_ error: NetworkError) -> NetworkError { + Logger.shared.log(level: .error, category: .network, "\(error.localizedDescription):\n\(dump(error))") + return error + } + } + } + + let tokenInterceptor = TokenInterceptor() + let session = Session(tokenInterceptor: tokenInterceptor) + + return .init( + apiRequest: { request, isWithInterceptor in + return try await session.sendRequest(request, isWithInterceptor: isWithInterceptor) + } + ) + } +} diff --git a/Projects/Core/APIClient/Sources/APIReqeustLoader.swift b/Projects/Core/APIClient/Sources/APIReqeustLoader.swift deleted file mode 100644 index 828e7b0..0000000 --- a/Projects/Core/APIClient/Sources/APIReqeustLoader.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// APIReqeustLoader.swift -// APIClientInterface -// -// Created by 김지현 on 8/3/24. -// Copyright © 2024 PomoNyang. All rights reserved. -// -// MARK: CAT-98 : token interceptor 진행중 (8.4) -import Foundation -import APIClientInterface -import KeychainClientInterface - -open class APIRequestLoader { - - public var session: URLSession - public var urlConfiguration: URLSessionConfiguration - //public var tokenInterceptor: TokenInterceptor - - public init(configuration: URLSessionConfiguration = .default) { - self.urlConfiguration = configuration -#if PROD - self.session = URLSession(configuration: configuration) -#elseif DEV - let sessionDelegate = EventLoggerDelegate() - self.session = URLSession(configuration: configuration, delegate: sessionDelegate, delegateQueue: nil) -#endif - //self.tokenInterceptor = TokenInterceptor(session: self.session, keychainClient: keychainClient) - } - - public func fetchData( - target: T, - responseData: M.Type, - isWithInterceptor: Bool = true, - keychainClient: KeychainClient - ) async throws -> M { - let urlRequest = try await target.asURLRequest(keychainClient: keychainClient) - - /* - let session = isWithInterceptor ? ~~ - */ - let (data, response) = try await session.data(for: urlRequest) - - guard let httpResponse = response as? HTTPURLResponse else { - throw NetworkError.noResponseError - } - - switch httpResponse.statusCode { - case 200..<300: - do { - let decodedData = try JSONDecoder().decode(responseData, from: data) - return decodedData - } catch { - throw NetworkError.decodingError - } - case 401: - throw NetworkError.authorizationError - case 400..<500: - throw NetworkError.requestError("bad request") - case 500..<600: - throw NetworkError.serverError - default: - throw NetworkError.unknownError - } - } -} diff --git a/Projects/Core/APIClient/Sources/Plugin/EventLoggerDelegate.swift b/Projects/Core/APIClient/Sources/Plugin/EventLoggerDelegate.swift deleted file mode 100644 index 58a18d0..0000000 --- a/Projects/Core/APIClient/Sources/Plugin/EventLoggerDelegate.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// EventLoggerDelegate.swift -// APIClientInterface -// -// Created by 김지현 on 7/24/24. -// Copyright © 2024 PomoNyang. All rights reserved. -// - -import Foundation -import Shared - -class EventLoggerDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate, URLSessionDataDelegate { - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - // MARK: CAT-98 - 로그 안찍힘 - print("~~~~~~~~~~~~", data.toPrettyPrintedString) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { - if let request = task.originalRequest { - print("🛰 NETWORK Request LOG") - - print("URL: " + (request.url?.absoluteString ?? "")) - print("Method: " + (request.httpMethod ?? "")) - if let body = request.httpBody { - print("Body: " + (body.toPrettyPrintedString ?? "")) - } - } - - if let response = task.response as? HTTPURLResponse { - print("🛰 NETWORK Response LOG") - print("StatusCode: \(response.statusCode)") - } - } -} diff --git a/Projects/Core/APIClient/Sources/RefreshTokenDTO.swift b/Projects/Core/APIClient/Sources/RefreshTokenDTO.swift new file mode 100644 index 0000000..39ff50a --- /dev/null +++ b/Projects/Core/APIClient/Sources/RefreshTokenDTO.swift @@ -0,0 +1,19 @@ +// +// RefreshAccessTokenService.swift +// APIClient +// +// Created by 김지현 on 8/7/24. +// Copyright © 2024 PomoNyang. All rights reserved. +// + +import Foundation + +struct RefreshTokenRequestDTO: Encodable { + public var refreshToken: String +} +struct TokenResponseDTO: Decodable { + public var accessToken: String + public var accessTokenExpiredAt: String + public var refreshToken: String + public var refreshTokenExpiredAt: String +} diff --git a/Projects/Core/APIClient/Sources/TokenInterceptor.swift b/Projects/Core/APIClient/Sources/TokenInterceptor.swift index 8679aa1..dc46ca5 100644 --- a/Projects/Core/APIClient/Sources/TokenInterceptor.swift +++ b/Projects/Core/APIClient/Sources/TokenInterceptor.swift @@ -9,37 +9,67 @@ import Foundation import KeychainClientInterface import APIClientInterface +import Logger +import Dependencies -public class TokenInterceptor: TokenInterceptorInterface { - public var session: URLSession - public var keychainClient: KeychainClient +enum KeychainClientKeys: String { + case accessToken = "mohanyang_keychain_access_token" + case refreshToken = "mohanyang_keychain_refresh_token" +} - public init(session: URLSession, keychainClient: KeychainClient) { - self.session = session - self.keychainClient = keychainClient - } +struct TokenInterceptor { + @Dependency(KeychainClient.self) var keychainClient - public func adapt(_ request: URLRequest) async throws -> URLRequest { + /// add accessToken to url request's header + func adapt( + _ request: URLRequest + ) async throws -> URLRequest { var requestWithToken = request - if let accessToken = keychainClient.read(key: "mohanyang_keychain_access_token") { - requestWithToken.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + if let accessToken = keychainClient.read(key: KeychainClientKeys.accessToken.rawValue) { + requestWithToken.addValue( + "Bearer \(accessToken)", + forHTTPHeaderField: HTTPHeaderField.authentication.rawValue + ) return requestWithToken } else { throw NetworkError.authorizationError } - } - public func retry(_ request: URLRequest, dueTo error: Error) async -> Bool { - do { - try await refreshAccessToken() - return true - } catch { - return false + /// refresh access token + func retry( + for session: URLSession + ) async throws { + + guard let refreshToken = keychainClient.read(key: KeychainClientKeys.refreshToken.rawValue) else { + throw NetworkError.authorizationError + } + + var urlRequest = URLRequest(url: URL(string: "https://" + API.apiBaseURL)!) + urlRequest.httpMethod = HTTPMethod.post.rawValue + urlRequest.setValue( + ContentType.json.rawValue, + forHTTPHeaderField: HTTPHeaderField.contentType.rawValue + ) + + let requestBody = ["refreshToken": refreshToken] + urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: requestBody, options: []) + + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + let error = NetworkError.noResponseError + Logger.shared.log(level: .error, category: .network, "API Error:\n\(dump(error))") + throw error + } + + guard (200...299).contains(httpResponse.statusCode) else { + let error = NetworkError.authorizationError + Logger.shared.log(level: .error, category: .network, "API Error:\n\(dump(error))") + throw error } - } - private func refreshAccessToken() async throws { -// /refresh 호출하는 곳 + let decodedData = try JSONDecoder().decode(TokenResponseDTO.self, from: data) + _ = keychainClient.update(key: KeychainClientKeys.accessToken.rawValue, data: decodedData.accessToken) } } diff --git a/Projects/Domain/AuthService/Interface/API/AuthAPIClientInterface.swift b/Projects/Domain/AuthService/Interface/API/AuthAPIClientInterface.swift index 3b48508..bf0308f 100644 --- a/Projects/Domain/AuthService/Interface/API/AuthAPIClientInterface.swift +++ b/Projects/Domain/AuthService/Interface/API/AuthAPIClientInterface.swift @@ -7,14 +7,20 @@ // import Foundation + +import APIClientInterface +import KeychainClientInterface + import Dependencies import DependenciesMacros -import KeychainClientInterface @DependencyClient public struct AuthAPIClient { - public var getToken: @Sendable (_ deviceID: String, _ keychainClient: KeychainClient) async throws -> AuthDTO.Response.TokenResponseDTO? + public var login: @Sendable ( + _ deviceID: String, + _ apiClient: APIClient + ) async throws -> AuthDTO.Response.TokenResponseDTO } extension AuthAPIClient: TestDependencyKey { diff --git a/Projects/Domain/AuthService/Interface/API/AuthAPIService.swift b/Projects/Domain/AuthService/Interface/API/AuthAPIService.swift index ce46a7c..12773d3 100644 --- a/Projects/Domain/AuthService/Interface/API/AuthAPIService.swift +++ b/Projects/Domain/AuthService/Interface/API/AuthAPIService.swift @@ -10,39 +10,33 @@ import Foundation import APIClientInterface public enum AuthAPIService { - case getToken(_ deviceId: String) - case refreshToken(_ refreshToken: String) + case login(_ deviceId: String) } -extension AuthAPIService: TargetType { +extension AuthAPIService: APIBaseRequest { public var baseURL: String { return API.apiBaseURL } - + public var path: String { switch self { - case .getToken: + case .login: return "/papi/v1/tokens" - case .refreshToken: - return "/papi/v1/tokens/refresh" } } public var method: HTTPMethod { switch self { - case .getToken, .refreshToken: + case .login: return .post } } public var parameters: RequestParams { switch self { - case let .getToken(deviceId): + case let .login(deviceId): let dto = AuthDTO.Request.GetTokenRequestDTO(deviceId: deviceId) return .body(dto) - case let .refreshToken(refreshToken): - let dto = AuthDTO.Request.RefreshTokenRequestDTO(refreshToken: refreshToken) - return .body(dto) } } } diff --git a/Projects/Domain/AuthService/Interface/DTO/AuthDTO.swift b/Projects/Domain/AuthService/Interface/DTO/AuthDTO.swift index 02253f7..8867dbb 100644 --- a/Projects/Domain/AuthService/Interface/DTO/AuthDTO.swift +++ b/Projects/Domain/AuthService/Interface/DTO/AuthDTO.swift @@ -17,10 +17,6 @@ public extension AuthDTO.Request { struct GetTokenRequestDTO: Encodable { public var deviceId: String } - - struct RefreshTokenRequestDTO: Encodable { - public var refreshToken: String - } } public extension AuthDTO.Response { diff --git a/Projects/Domain/AuthService/Sources/API/AuthAPIClient.swift b/Projects/Domain/AuthService/Sources/API/AuthAPIClient.swift index 7d79268..4948a6d 100644 --- a/Projects/Domain/AuthService/Sources/API/AuthAPIClient.swift +++ b/Projects/Domain/AuthService/Sources/API/AuthAPIClient.swift @@ -13,10 +13,15 @@ import Dependencies extension AuthAPIClient: DependencyKey { public static let liveValue: AuthAPIClient = .live() - private static func live() -> AuthAPIClient { + private static func live() -> Self { return AuthAPIClient( - getToken: { deviceID, keychainClient in - var response = try await LocalAuthAPI().getToken(deviceID, keychainClient) + login: { deviceID, apiClient in + let service = AuthAPIService.login(deviceID) + let response = try await apiClient.apiRequest( + request: service, + as: AuthDTO.Response.TokenResponseDTO.self, + isWithInterceptor: false + ) return response } ) diff --git a/Projects/Domain/AuthService/Sources/API/LocalAuthAPI.swift b/Projects/Domain/AuthService/Sources/API/LocalAuthAPI.swift deleted file mode 100644 index 767a0ac..0000000 --- a/Projects/Domain/AuthService/Sources/API/LocalAuthAPI.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// AuthAPI.swift -// AuthService -// -// Created by 김지현 on 8/3/24. -// Copyright © 2024 PomoNyang. All rights reserved. -// - -import Foundation -import APIClient -import AuthServiceInterface -import KeychainClientInterface - -class LocalAuthAPI: APIRequestLoader { - func getToken(_ deviceId: String, _ keychainClient: KeychainClient) async throws -> AuthDTO.Response.TokenResponseDTO { - return try await fetchData( - target: .getToken(deviceId), - responseData: AuthDTO.Response.TokenResponseDTO.self, - keychainClient: keychainClient - ) - } -} diff --git a/Projects/Feature/HomeFeature/Sources/HomeCore.swift b/Projects/Feature/HomeFeature/Sources/HomeCore.swift index 728638b..d2cd2fa 100644 --- a/Projects/Feature/HomeFeature/Sources/HomeCore.swift +++ b/Projects/Feature/HomeFeature/Sources/HomeCore.swift @@ -8,6 +8,8 @@ import UserNotifications +import APIClientInterface +import AuthServiceInterface import HomeFeatureInterface import PushService import UserNotificationClientInterface @@ -17,12 +19,15 @@ import ComposableArchitecture extension HomeCore { public init() { @Dependency(UserNotificationClient.self) var userNotificationClient - + @Dependency(APIClient.self) var apiClient + @Dependency(AuthAPIClient.self) var authAPIClient + let reducer = Reduce { _, action in switch action { case .onAppear: return .run { send in _ = try await userNotificationClient.requestAuthorization([.alert, .badge, .sound]) + _ = try await authAPIClient.login(deviceID: "deviceID", apiClient: apiClient) } case .localPushButtonTapped: diff --git a/XCConfig/Project/Mohanyang.xcconfig b/XCConfig/Project/Mohanyang.xcconfig index d1b461f..300f61a 100644 --- a/XCConfig/Project/Mohanyang.xcconfig +++ b/XCConfig/Project/Mohanyang.xcconfig @@ -2,7 +2,7 @@ APP_NAME = 모하냥 -BASE_URL_DEV = dev //dev +BASE_URL_DEV = dev.api.pomonyang.com //dev BASE_URL_PROD = prod //prod BASE_URL = $(BASE_URL_$(CONFIGURATION)) MARKETING_VERSION = 0.0.1