diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index 4bab84f..542e628 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -19,7 +19,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { assembler = Assembler([ KeychainAssembly(), - PresentationAssembly() + PresentationAssembly(), + DataSourceAssembly(), + RepositoryAssembly(), + UseCaseAssembly() ], container: AppDelegate.container) return true } diff --git a/Projects/App/Support/Info.plist b/Projects/App/Support/Info.plist index f3d6b46..0f54b8d 100644 --- a/Projects/App/Support/Info.plist +++ b/Projects/App/Support/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + API_BASE_URL $(API_BASE_URL) AppIdentifierPrefix diff --git a/Projects/Core/Sources/JwtStore/JwtStore.swift b/Projects/Core/Sources/JwtStore/JwtStore.swift index 22bc967..182c177 100644 --- a/Projects/Core/Sources/JwtStore/JwtStore.swift +++ b/Projects/Core/Sources/JwtStore/JwtStore.swift @@ -1,8 +1,5 @@ public enum KeychainType: String { case accessToken = "ACCESS-TOKEN" - case refreshToken = "REFRESH-TOKEN" - case accessExpiresAt = "ACCESS-EXPIRED-AT" - case refreshExpiresAt = "REFRESH-EXPIRED-AT" } public protocol Keychain { diff --git a/Projects/Data/Sources/Auth/DataSource/API/AuthAPI.swift b/Projects/Data/Sources/Auth/DataSource/API/AuthAPI.swift new file mode 100644 index 0000000..8be5ca5 --- /dev/null +++ b/Projects/Data/Sources/Auth/DataSource/API/AuthAPI.swift @@ -0,0 +1,47 @@ +import Moya +import Domain +import AppNetwork + +enum AuthAPI { + case login(LoginRequestQuery) + case signup(SignupRequestQuery) +} + +extension AuthAPI: EmotingAPI { + typealias ErrorType = Error + + var domain: EmotingDomain { + .auth + } + + var urlPath: String { + switch self { + case .login: + return "/login" + case .signup: + return "/signup" + } + } + + var method: Method { + return .post + } + + var task: Task { + switch self { + case let .login(req): + return .requestJSONEncodable(req) + + case let .signup(req): + return .requestJSONEncodable(req) + } + } + + var jwtTokenType: JwtTokenType { + return .none + } + + var errorMap: [Int: ErrorType]? { + return nil + } +} diff --git a/Projects/Data/Sources/Auth/DataSource/Remote/RemoteAuthDataSource.swift b/Projects/Data/Sources/Auth/DataSource/Remote/RemoteAuthDataSource.swift new file mode 100644 index 0000000..36f5094 --- /dev/null +++ b/Projects/Data/Sources/Auth/DataSource/Remote/RemoteAuthDataSource.swift @@ -0,0 +1,21 @@ +import RxSwift +import Domain +import AppNetwork + +protocol RemoteAuthDataSource { + func login(req: LoginRequestQuery) -> Single + func signup(req: SignupRequestQuery) -> Completable +} + +final class RemoteAuthDataSourceImpl: RemoteBaseDataSource, RemoteAuthDataSource { + func login(req: LoginRequestQuery) -> Single { + return request(.login(req)) + .map(TokenDTO.self) + } + + func signup(req: SignupRequestQuery) -> Completable { + return request(.signup(req)) + .asCompletable() + } + +} diff --git a/Projects/Data/Sources/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Sources/Auth/Repository/AuthRepositoryImpl.swift new file mode 100644 index 0000000..1a441be --- /dev/null +++ b/Projects/Data/Sources/Auth/Repository/AuthRepositoryImpl.swift @@ -0,0 +1,19 @@ +import RxSwift +import Domain + +struct AuthRepositoryImpl: AuthRepository { + private let remoteAuthDataSource: any RemoteAuthDataSource + + init(remoteAuthDataSource: any RemoteAuthDataSource) { + self.remoteAuthDataSource = remoteAuthDataSource + } + + func login(req: Domain.LoginRequestQuery) -> RxSwift.Completable { + remoteAuthDataSource.login(req: req) + .asCompletable() + } + + func signup(req: Domain.SignupRequestQuery) -> RxSwift.Completable { + remoteAuthDataSource.signup(req: req) + } +} diff --git a/Projects/Data/Sources/Base/BaseRemoteDataSource.swift b/Projects/Data/Sources/Base/BaseRemoteDataSource.swift new file mode 100644 index 0000000..e4c3498 --- /dev/null +++ b/Projects/Data/Sources/Base/BaseRemoteDataSource.swift @@ -0,0 +1,51 @@ +import Moya +import Domain +import AppNetwork +import Foundation +import RxSwift +import RxMoya +import Core +import Alamofire + +class RemoteBaseDataSource { + private let keychain: any Keychain + + private let provider: MoyaProvider + + init(keychain: any Keychain) { + self.keychain = keychain +#if DEBUG + self.provider = MoyaProvider(plugins: [JwtPlugin(keychain: keychain), MoyaLogginPlugin()]) +#else + self.provider = MoyaProvider(plugins: [JwtPlugin(keychain: keychain)]) +#endif + } + + func request(_ api: API) -> Single { + return .create { single in + var disposables: [Disposable] = [] + disposables.append( + self.defaultRequest(api) + .subscribe( + onSuccess: { single(.success($0)) }, + onFailure: { single(.failure($0)) } + ) + ) + return Disposables.create(disposables) + } + } +} + +private extension RemoteBaseDataSource { + func defaultRequest(_ api: API) -> Single { + return provider.rx + .request(api) + .timeout(.seconds(120), scheduler: MainScheduler.asyncInstance) + .catch { error in + guard let code = (error as? MoyaError)?.response?.statusCode else { + return .error(error) + } + return .error(api.errorMap?[code] ?? error) + } + } +} diff --git a/Projects/Data/Sources/DI/DataSourceAssembly.swift b/Projects/Data/Sources/DI/DataSourceAssembly.swift new file mode 100644 index 0000000..2a32b63 --- /dev/null +++ b/Projects/Data/Sources/DI/DataSourceAssembly.swift @@ -0,0 +1,18 @@ +import Foundation +import Swinject +import Core +import Domain + +public final class DataSourceAssembly: Assembly { + public init() {} + + private let keychain = { (resolver: Resolver) in + resolver.resolve(Keychain.self)! + } + + public func assemble(container: Container) { + container.register(RemoteAuthDataSource.self) { resolver in + RemoteAuthDataSourceImpl(keychain: self.keychain(resolver)) + } + } +} diff --git a/Projects/Data/Sources/DI/RepositoryAssembly.swift b/Projects/Data/Sources/DI/RepositoryAssembly.swift new file mode 100644 index 0000000..257e3e2 --- /dev/null +++ b/Projects/Data/Sources/DI/RepositoryAssembly.swift @@ -0,0 +1,15 @@ +import Foundation +import Swinject +import Domain + +public final class RepositoryAssembly: Assembly { + public init() {} + + // swiftlint:disable function_body_length + public func assemble(container: Container) { + container.register(AuthRepository.self) { resolver in + AuthRepositoryImpl(remoteAuthDataSource: resolver.resolve(RemoteAuthDataSource.self)!) + } + } + // swiftlint:enable function_body_length +} diff --git a/Projects/Data/Sources/DI/UseCaseAssembly.swift b/Projects/Data/Sources/DI/UseCaseAssembly.swift new file mode 100644 index 0000000..6175920 --- /dev/null +++ b/Projects/Data/Sources/DI/UseCaseAssembly.swift @@ -0,0 +1,23 @@ +import Foundation +import Swinject +import Domain + +public final class UseCaseAssembly: Assembly { + public init() {} + +// swiftlint:disable function_body_length + public func assemble(container: Container) { + // Auth + container.register(LoginUseCase.self) { resolver in + LoginUseCase( + authRepository: resolver.resolve(AuthRepository.self)! + ) + } + container.register(SignupUseCase.self) { resolver in + SignupUseCase( + authRepository: resolver.resolve(AuthRepository.self)! + ) + } + } + // swiftlint:enable function_body_length +} diff --git a/Projects/Data/Sources/TempFile.swift b/Projects/Data/Sources/TempFile.swift deleted file mode 100644 index 19be644..0000000 --- a/Projects/Data/Sources/TempFile.swift +++ /dev/null @@ -1 +0,0 @@ -// TempFile diff --git a/Projects/Domain/Sources/Auth/Parameter/LoginRequestQuery.swift b/Projects/Domain/Sources/Auth/Parameter/LoginRequestQuery.swift new file mode 100644 index 0000000..057d4ba --- /dev/null +++ b/Projects/Domain/Sources/Auth/Parameter/LoginRequestQuery.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct LoginRequestQuery: Encodable { + public let email: String + public let password: String + + public init(email: String, password: String) { + self.email = email + self.password = password + } +} diff --git a/Projects/Domain/Sources/Auth/Parameter/SignupRequestQuery.swift b/Projects/Domain/Sources/Auth/Parameter/SignupRequestQuery.swift new file mode 100644 index 0000000..97a08e3 --- /dev/null +++ b/Projects/Domain/Sources/Auth/Parameter/SignupRequestQuery.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct SignupRequestQuery: Encodable { + public let email: String + public let password: String + public let nickname: String + public let age: Int + + public init(email: String, password: String, nickname: String, age: Int) { + self.email = email + self.password = password + self.nickname = nickname + self.age = age + } +} diff --git a/Projects/Domain/Sources/Auth/Repository/AuthRepository.swift b/Projects/Domain/Sources/Auth/Repository/AuthRepository.swift new file mode 100644 index 0000000..86532bc --- /dev/null +++ b/Projects/Domain/Sources/Auth/Repository/AuthRepository.swift @@ -0,0 +1,6 @@ +import RxSwift + +public protocol AuthRepository { + func login(req: LoginRequestQuery) -> Completable + func signup(req: SignupRequestQuery) -> Completable +} diff --git a/Projects/Domain/Sources/Auth/UseCase/LoginUseCase.swift b/Projects/Domain/Sources/Auth/UseCase/LoginUseCase.swift new file mode 100644 index 0000000..0402e9e --- /dev/null +++ b/Projects/Domain/Sources/Auth/UseCase/LoginUseCase.swift @@ -0,0 +1,13 @@ +import RxSwift + +public struct LoginUseCase { + public init(authRepository: AuthRepository) { + self.authRepository = authRepository + } + + private let authRepository: AuthRepository + + public func execute(req: LoginRequestQuery) -> Completable { + return authRepository.login(req: req) + } +} diff --git a/Projects/Domain/Sources/Auth/UseCase/SignupUseCase.swift b/Projects/Domain/Sources/Auth/UseCase/SignupUseCase.swift new file mode 100644 index 0000000..2892fc2 --- /dev/null +++ b/Projects/Domain/Sources/Auth/UseCase/SignupUseCase.swift @@ -0,0 +1,13 @@ +import RxSwift + +public struct SignupUseCase { + public init(authRepository: AuthRepository) { + self.authRepository = authRepository + } + + private let authRepository: AuthRepository + + public func execute(req: SignupRequestQuery) -> Completable { + return authRepository.signup(req: req) + } +} diff --git a/Projects/Domain/Sources/TempFile.swift b/Projects/Domain/Sources/TempFile.swift deleted file mode 100644 index 19be644..0000000 --- a/Projects/Domain/Sources/TempFile.swift +++ /dev/null @@ -1 +0,0 @@ -// TempFile diff --git a/Projects/Modules/AppNetwork/Sources/Plugin/EmotingAPI.swift b/Projects/Modules/AppNetwork/Sources/Plugin/EmotingAPI.swift new file mode 100644 index 0000000..643d0f8 --- /dev/null +++ b/Projects/Modules/AppNetwork/Sources/Plugin/EmotingAPI.swift @@ -0,0 +1,45 @@ +import Foundation +import Moya + +public protocol EmotingAPI: TargetType, JwtAuthorizable { + associatedtype ErrorType: Error + var domain: EmotingDomain { get } + var urlPath: String { get } + var errorMap: [Int: ErrorType]? { get } +} + +public extension EmotingAPI { + var baseURL: URL { + URL( + string: "http://52.79.170.221:8080" + ) ?? URL(string: "https://www.google.com")! + } + + var path: String { + domain.asURLString + urlPath + } + + var headers: [String: String]? { + ["Content-Type": "application/json"] + } + + var validationType: ValidationType { + return .successCodes + } +} + +public enum EmotingDomain: String { + case auth +} + +extension EmotingDomain { + var asURLString: String { + "/\(self.rawValue)" + } +} + +private class BundleFinder {} + +extension Foundation.Bundle { + static let module = Bundle(for: BundleFinder.self) +} diff --git a/Projects/Modules/AppNetwork/Sources/Plugin/Jwt/JwtAuthorizable.swift b/Projects/Modules/AppNetwork/Sources/Plugin/Jwt/JwtAuthorizable.swift new file mode 100644 index 0000000..be2ed51 --- /dev/null +++ b/Projects/Modules/AppNetwork/Sources/Plugin/Jwt/JwtAuthorizable.swift @@ -0,0 +1,10 @@ +import Moya + +public enum JwtTokenType: String { + case accessToken = "Authorization" + case none +} + +public protocol JwtAuthorizable { + var jwtTokenType: JwtTokenType { get } +} diff --git a/Projects/Modules/AppNetwork/Sources/Plugin/Jwt/JwtPlugin.swift b/Projects/Modules/AppNetwork/Sources/Plugin/Jwt/JwtPlugin.swift new file mode 100644 index 0000000..cfb0470 --- /dev/null +++ b/Projects/Modules/AppNetwork/Sources/Plugin/Jwt/JwtPlugin.swift @@ -0,0 +1,49 @@ +import Moya +import Core +import Foundation + +public struct JwtPlugin: PluginType { + private let keychain: any Keychain + + public init(keychain: any Keychain) { + self.keychain = keychain + } + + public func prepare( + _ request: URLRequest, + target: TargetType + ) -> URLRequest { + guard let jwtTokenType = (target as? JwtAuthorizable)?.jwtTokenType, + jwtTokenType != .none + else { return request } + var req = request + let token = "\(getToken(type: .accessToken))" + + req.addValue(token, forHTTPHeaderField: jwtTokenType.rawValue) + return req + } + + public func didReceive( + _ result: Result, + target: TargetType + ) { + switch result { + case let .success(res): + if let new = try? res.map(TokenDTO.self) { + saveToken(token: new) + } + default: + break + } + } +} + +private extension JwtPlugin { + func getToken(type: KeychainType) -> String { + return "Bearer \(keychain.load(type: .accessToken))" + } + + func saveToken(token: TokenDTO) { + keychain.save(type: .accessToken, value: token.accessToken) + } +} diff --git a/Projects/Modules/AppNetwork/Sources/Plugin/Jwt/TokenDTO.swift b/Projects/Modules/AppNetwork/Sources/Plugin/Jwt/TokenDTO.swift new file mode 100644 index 0000000..6ba707b --- /dev/null +++ b/Projects/Modules/AppNetwork/Sources/Plugin/Jwt/TokenDTO.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct TokenDTO: Equatable, Decodable { + let accessToken: String + + enum CodingKeys: String, CodingKey { + case accessToken + } +} diff --git a/Projects/Modules/AppNetwork/Sources/Plugin/Logging/MoyaLoggingPlugin.swift b/Projects/Modules/AppNetwork/Sources/Plugin/Logging/MoyaLoggingPlugin.swift new file mode 100644 index 0000000..3c42415 --- /dev/null +++ b/Projects/Modules/AppNetwork/Sources/Plugin/Logging/MoyaLoggingPlugin.swift @@ -0,0 +1,66 @@ +import Foundation +import Moya + +#if DEBUG +// swiftlint: disable line_length +public final class MoyaLogginPlugin: PluginType { + public init() {} + public func willSend(_ request: RequestType, target: TargetType) { + guard let httpRequest = request.request else { + print("--> 유효하지 않은 요청") + return + } + let url = httpRequest.description + let method = httpRequest.httpMethod ?? "unknown method" + var log = "----------------------------------------------------\n\n[\(method)] \(url)\n\n----------------------------------------------------\n" + log.append("API: \(target)\n") + if let headers = httpRequest.allHTTPHeaderFields, !headers.isEmpty { + log.append("header: \(headers)\n") + } + if let body = httpRequest.httpBody, let bodyString = String(bytes: body, encoding: String.Encoding.utf8) { + log.append("\(bodyString)\n") + } + log.append("------------------- END \(method) --------------------------\n") + print(log) + } + + public func didReceive(_ result: Result, target: TargetType) { + switch result { + case let .success(response): + onSuceed(response, target: target, isFromError: false) + case let .failure(error): + onFail(error, target: target) + } + } + + func onSuceed(_ response: Response, target: TargetType, isFromError: Bool) { + let request = response.request + let url = request?.url?.absoluteString ?? "nil" + let statusCode = response.statusCode + var log = "------------------- 네트워크 통신 성공 -------------------" + log.append("\n[\(statusCode)] \(url)\n----------------------------------------------------\n") + log.append("API: \(target)\n") + response.response?.allHeaderFields.forEach { + log.append("\($0): \($1)\n") + } + if let reString = String(bytes: response.data, encoding: String.Encoding.utf8) { + log.append("\(reString)\n") + } + log.append("------------------- END HTTP (\(response.data.count)-byte body) -------------------\n") + print(log) + } + + func onFail(_ error: MoyaError, target: TargetType) { + if let response = error.response { + onSuceed(response, target: target, isFromError: true) + return + } + var log = "네트워크 오류" + log.append("<-- \(error.errorCode) \(target)\n") + log.append("\(error.failureReason ?? error.errorDescription ?? "unknown error")\n") + log.append("<-- END HTTP\n") + print(log) + } +} +// swiftlint: enable line_length +#endif diff --git a/Projects/Modules/AppNetwork/Sources/TempFile.swift b/Projects/Modules/AppNetwork/Sources/TempFile.swift deleted file mode 100644 index 19be644..0000000 --- a/Projects/Modules/AppNetwork/Sources/TempFile.swift +++ /dev/null @@ -1 +0,0 @@ -// TempFile diff --git a/Projects/Presentation/Sources/Auth/Login/EmailLogin/EmailLoginReactor.swift b/Projects/Presentation/Sources/Auth/Login/EmailLogin/EmailLoginReactor.swift index 6d1e6a6..ca902c1 100644 --- a/Projects/Presentation/Sources/Auth/Login/EmailLogin/EmailLoginReactor.swift +++ b/Projects/Presentation/Sources/Auth/Login/EmailLogin/EmailLoginReactor.swift @@ -7,18 +7,65 @@ import Core public final class EmailLoginReactor: BaseReactor, Stepper { public let steps = PublishRelay() - public let initialState = State() + public var initialState = State() private let disposeBag = DisposeBag() - public init() {} + private let loginUseCase: LoginUseCase - public enum Action { } + init(loginUseCase: LoginUseCase) { + self.initialState = .init() + self.loginUseCase = loginUseCase + } - public enum Mutation { } + public enum Action { + case updateEmail(String) + case updatePassword(String) + case loginButtonDidTap + } - public struct State { } + public enum Mutation { + case updateEmail(String) + case updatePassword(String) + case loginSuccess + } + + public struct State { + var email: String = "" + var passowrd: String = "" + } } extension EmailLoginReactor { - public func mutate(action: Action) -> Observable { } - public func reduce(state: State, mutation: Mutation) -> State { } + public func mutate(action: Action) -> Observable { + switch action { + case let .updateEmail(email): + return .just(.updateEmail(email)) + case let .updatePassword(password): + return .just(.updatePassword(password)) + case .loginButtonDidTap: + return loginButtonDidTap( + email: self.currentState.email, + password: self.currentState.passowrd + ) + } + } + public func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case let .updateEmail(email): + newState.email = email + case let .updatePassword(password): + newState.passowrd = password + case .loginSuccess: + steps.accept(EmailLoginStep.tabIsRequired) + } + return newState + } + + private func loginButtonDidTap(email: String, password: String) -> Observable { + return self.loginUseCase.execute(req: .init( + email: email, + password: password + )) + .andThen(.just(Mutation.loginSuccess)) + } } diff --git a/Projects/Presentation/Sources/Auth/Login/EmailLogin/EmailLoginViewController.swift b/Projects/Presentation/Sources/Auth/Login/EmailLogin/EmailLoginViewController.swift index 33f5379..4e605d3 100644 --- a/Projects/Presentation/Sources/Auth/Login/EmailLogin/EmailLoginViewController.swift +++ b/Projects/Presentation/Sources/Auth/Login/EmailLogin/EmailLoginViewController.swift @@ -2,6 +2,8 @@ import UIKit import SnapKit import Then import DesignSystem +import RxSwift +import RxCocoa public class EmailLoginViewController: BaseReactorViewController { private let titleLabel = UILabel().then { @@ -24,10 +26,29 @@ public class EmailLoginViewController: BaseReactorViewController