diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+SPM.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+SPM.swift index 574dad5d..c1598e15 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+SPM.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+SPM.swift @@ -13,4 +13,6 @@ public extension TargetDependency.SPM { static let Then = TargetDependency.external(name: "Then") static let RxFlow = TargetDependency.external(name: "RxFlow") static let Swinject = TargetDependency.external(name: "Swinject") + static let Moya = TargetDependency.external(name: "Moya") + static let RxMoya = TargetDependency.external(name: "RxMoya") } diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index d4209240..deb7ee81 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -1,41 +1,42 @@ import UIKit +import Data import Swinject import Then import Presentation +import Domain +import Core @main class AppDelegate: UIResponder, UIApplicationDelegate { - - static var container: Container { - let container = Container() - container.register(MainViewModel.self) { _ in - MainViewModel() - } - container.register(MainViewController.self) { resolver in - return MainViewController(resolver.resolve(MainViewModel.self)!) - } - return container - } - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + + static var container = Container() + var assembler: Assembler! + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + assembler = Assembler([ + KeychainAssembly(), + DataSourceAssembly(), + RepositoryAssembly(), + UseCaseAssembly(), + PresentationAssembly() + ], container: AppDelegate.container) return true } // MARK: UISceneSession Lifecycle - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. - } - - + func application( + _ application: UIApplication, + didDiscardSceneSessions sceneSessions: Set + ) { } } - diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index 09a799e8..cb53967e 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -15,30 +15,20 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window = UIWindow(windowScene: windowScence) window?.windowScene = windowScence window?.backgroundColor = .white - + let appFlow = AppFlow(window: window!, container: AppDelegate.container) self.coordinator.coordinate(flow: appFlow, with: AppStepper()) window?.makeKeyAndVisible() } - - func sceneDidDisconnect(_ scene: UIScene) { - - } - - func sceneDidBecomeActive(_ scene: UIScene) { - - } - func sceneWillResignActive(_ scene: UIScene) { + func sceneDidDisconnect(_ scene: UIScene) { } - } + func sceneDidBecomeActive(_ scene: UIScene) { } - func sceneWillEnterForeground(_ scene: UIScene) { + func sceneWillResignActive(_ scene: UIScene) { } - } + func sceneWillEnterForeground(_ scene: UIScene) { } - func sceneDidEnterBackground(_ scene: UIScene) { - - } + func sceneDidEnterBackground(_ scene: UIScene) { } } diff --git a/Projects/Core/Sources/Base/BaseViewController.swift b/Projects/Core/Sources/Base/BaseViewController.swift index c6c78193..75eaa460 100644 --- a/Projects/Core/Sources/Base/BaseViewController.swift +++ b/Projects/Core/Sources/Base/BaseViewController.swift @@ -2,12 +2,12 @@ import UIKit import RxCocoa import RxSwift -open class BaseViewController: UIViewController { - public let viewModel: T +open class BaseViewController: UIViewController { + public let viewModel: ViewModel public var disposeBag = DisposeBag() let bounds = UIScreen.main.bounds - public init(_ viewModel: T) { + public init(_ viewModel: ViewModel) { self.viewModel = viewModel super .init(nibName: nil, bundle: nil) } @@ -23,12 +23,11 @@ open class BaseViewController: UIViewController { open func addView() { } open func layout() { } - + open func bind() { } open func attribute() {} - required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/Projects/Core/Sources/Base/BaseViewModel.swift b/Projects/Core/Sources/Base/BaseViewModel.swift index 76783f5f..1b641800 100644 --- a/Projects/Core/Sources/Base/BaseViewModel.swift +++ b/Projects/Core/Sources/Base/BaseViewModel.swift @@ -3,6 +3,6 @@ import UIKit public protocol BaseViewModel { associatedtype Input associatedtype Output - + func transform(_ input: Input) -> Output } diff --git a/Projects/Core/Sources/JwtStore/JwtImpl.swift b/Projects/Core/Sources/JwtStore/JwtImpl.swift new file mode 100644 index 00000000..3170ed88 --- /dev/null +++ b/Projects/Core/Sources/JwtStore/JwtImpl.swift @@ -0,0 +1,48 @@ +import Foundation + +public struct KeychainImpl: Keychain { + public init() {} + + private let bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "" + private let appIdentifierPrefix = Bundle.main.infoDictionary!["AppIdentifierPrefix"] as? String ?? "" + private var accessGroup: String { + "\(appIdentifierPrefix)com.team.jobis.JOBIS-DSM-iOS-v2.keychainGroup" + } + + public func save(type: KeychainType, value: String) { + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: type.rawValue, + kSecValueData: value.data(using: .utf8, allowLossyConversion: false) ?? .init(), + kSecAttrAccessGroup: accessGroup + ] + SecItemDelete(query) + SecItemAdd(query, nil) + } + + public func load(type: KeychainType) -> String { + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: type.rawValue, + kSecReturnData: kCFBooleanTrue!, + kSecMatchLimit: kSecMatchLimitOne, + kSecAttrAccessGroup: accessGroup + ] + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + if status == errSecSuccess { + guard let data = dataTypeRef as? Data else { return "" } + return String(data: data, encoding: .utf8) ?? "" + } + return "" + } + + public func delete(type: KeychainType) { + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: type.rawValue, + kSecAttrAccessGroup: accessGroup + ] + SecItemDelete(query) + } +} diff --git a/Projects/Core/Sources/JwtStore/JwtStore.swift b/Projects/Core/Sources/JwtStore/JwtStore.swift new file mode 100644 index 00000000..22bc9674 --- /dev/null +++ b/Projects/Core/Sources/JwtStore/JwtStore.swift @@ -0,0 +1,12 @@ +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 { + func save(type: KeychainType, value: String) + func load(type: KeychainType) -> String + func delete(type: KeychainType) +} diff --git a/Projects/Core/Sources/JwtStore/KeychainAssembly.swift b/Projects/Core/Sources/JwtStore/KeychainAssembly.swift new file mode 100644 index 00000000..83b60741 --- /dev/null +++ b/Projects/Core/Sources/JwtStore/KeychainAssembly.swift @@ -0,0 +1,11 @@ +import Foundation +import Swinject +public final class KeychainAssembly: Assembly { + public init() {} + + public func assemble(container: Container) { + container.register(Keychain.self) { _ in + KeychainImpl() + } + } +} diff --git a/Projects/Core/Sources/Steps/MainStep.swift b/Projects/Core/Sources/Steps/MainStep.swift index 2af9741a..30283dd6 100644 --- a/Projects/Core/Sources/Steps/MainStep.swift +++ b/Projects/Core/Sources/Steps/MainStep.swift @@ -1,4 +1,3 @@ -import Foundation import RxFlow public enum MainStep: Step { diff --git a/Projects/Data/Sources/DI/DataSourceAssembly.swift b/Projects/Data/Sources/DI/DataSourceAssembly.swift new file mode 100644 index 00000000..5cdfc65e --- /dev/null +++ b/Projects/Data/Sources/DI/DataSourceAssembly.swift @@ -0,0 +1,14 @@ +import Foundation +import Swinject +import Core +import Domain + +public final class DataSourceAssembly: Assembly { + public init() {} + + public func assemble(container: Container) { + container.register(AuthRemote.self) { resolver in + AuthRemoteImpl(keychainLocal: resolver.resolve(Keychain.self)!) + } + } +} diff --git a/Projects/Data/Sources/DI/RepositoryAssembly.swift b/Projects/Data/Sources/DI/RepositoryAssembly.swift new file mode 100644 index 00000000..42e7b373 --- /dev/null +++ b/Projects/Data/Sources/DI/RepositoryAssembly.swift @@ -0,0 +1,13 @@ +import Foundation +import Swinject +import Domain + +public final class RepositoryAssembly: Assembly { + public init() {} + + public func assemble(container: Container) { + container.register(AuthRepository.self) { resolver in + AuthRepositoryImpl(authRemote: resolver.resolve(AuthRemote.self)!) + } + } +} diff --git a/Projects/Data/Sources/DI/UseCaseAssembly.swift b/Projects/Data/Sources/DI/UseCaseAssembly.swift new file mode 100644 index 00000000..4108f87b --- /dev/null +++ b/Projects/Data/Sources/DI/UseCaseAssembly.swift @@ -0,0 +1,27 @@ +import Foundation +import Swinject +import Domain + +public final class UseCaseAssembly: Assembly { + public init() {} + + public func assemble(container: Container) { + container.register(SendAuthCodeUseCase.self) { resolver in + SendAuthCodeUseCase( + authRepository: resolver.resolve(AuthRepository.self)! + ) + } + + container.register(VerifyAuthCodeUseCase.self) { resolver in + VerifyAuthCodeUseCase( + authRepository: resolver.resolve(AuthRepository.self)! + ) + } + + container.register(ReissueTokenUaseCase.self) { resolver in + ReissueTokenUaseCase( + authRepository: resolver.resolve(AuthRepository.self)! + ) + } + } +} diff --git a/Projects/Data/Sources/DTO/ReissueTokenResponseDTO.swift b/Projects/Data/Sources/DTO/ReissueTokenResponseDTO.swift new file mode 100644 index 00000000..3259e53c --- /dev/null +++ b/Projects/Data/Sources/DTO/ReissueTokenResponseDTO.swift @@ -0,0 +1,17 @@ +import Foundation +import Domain +import AppNetwork + +public struct ReissueTokenResponseDTO: Decodable { + public let authority: AuthorityType + + public init(authority: AuthorityType) { + self.authority = authority + } +} + +public extension ReissueTokenResponseDTO { + func toDomain() -> ReissueAuthorityEntity { + ReissueAuthorityEntity(authority: authority) + } +} diff --git a/Projects/Data/Sources/DataSource/API/AuthAPI.swift b/Projects/Data/Sources/DataSource/API/AuthAPI.swift new file mode 100644 index 00000000..ec147813 --- /dev/null +++ b/Projects/Data/Sources/DataSource/API/AuthAPI.swift @@ -0,0 +1,72 @@ +import Moya +import Domain +import AppNetwork + +public enum AuthAPI { + case verifyAuthCode(email: String, authCode: String) + case sendAuthCode(SendAuthCodeRequestQuery) + case reissueToken +} + +extension AuthAPI: JobisAPI { + public typealias ErrorType = JobisError + + public var domain: JobisDomain { + .auth + } + + public var urlPath: String { + switch self { + case .sendAuthCode, .verifyAuthCode: + return "/code" + + case .reissueToken: + return "/reissue" + } + } + + public var method: Method { + switch self { + case .sendAuthCode: + return .post + + case .verifyAuthCode: + return .patch + + case .reissueToken: + return .put + } + } + + public var task: Task { + switch self { + case let .sendAuthCode(req): + return .requestJSONEncodable(req) + + case let .verifyAuthCode(email, authCode): + return .requestParameters( + parameters: [ + "email": email, + "auth_code": authCode + ], encoding: URLEncoding.queryString + ) + + case .reissueToken: + return .requestPlain + } + } + + public var jwtTokenType: JwtTokenType { + switch self { + case .reissueToken: + return .refreshToken + + default: + return .none + } + } + + public var errorMap: [Int: ErrorType]? { + return nil + } +} diff --git a/Projects/Data/Sources/DataSource/Remote/AuthRemote.swift b/Projects/Data/Sources/DataSource/Remote/AuthRemote.swift new file mode 100644 index 00000000..e8f36142 --- /dev/null +++ b/Projects/Data/Sources/DataSource/Remote/AuthRemote.swift @@ -0,0 +1,27 @@ +import RxSwift +import Domain +import AppNetwork + +public protocol AuthRemote { + func sendAuthCode(req: SendAuthCodeRequestQuery) -> Completable + func reissueToken() -> Single + func verifyAuthCode(email: String, authCode: String) -> Completable +} + +final class AuthRemoteImpl: BaseRemote, AuthRemote { + func sendAuthCode(req: SendAuthCodeRequestQuery) -> Completable { + return request(.sendAuthCode(req)) + .asCompletable() + } + + func reissueToken() -> Single { + return request(.reissueToken) + .map(ReissueTokenResponseDTO.self) + .map { $0.toDomain() } + } + + func verifyAuthCode(email: String, authCode: String) -> Completable { + return request(.verifyAuthCode(email: email, authCode: authCode)) + .asCompletable() + } +} diff --git a/Projects/Data/Sources/DataSource/Remote/BaseRemote.swift b/Projects/Data/Sources/DataSource/Remote/BaseRemote.swift new file mode 100644 index 00000000..bec1145e --- /dev/null +++ b/Projects/Data/Sources/DataSource/Remote/BaseRemote.swift @@ -0,0 +1,108 @@ +import Moya +import Domain +import AppNetwork +import Foundation +import RxSwift +import RxMoya +import Core +import Alamofire + +class BaseRemote { + private let keychainLocal: any Keychain + + private let provider: MoyaProvider + + init(keychainLocal: any Keychain) { + self.keychainLocal = keychainLocal +#if DEBUG + self.provider = MoyaProvider(plugins: [JwtPlugin(keychain: keychainLocal), MoyaLogginPlugin()]) +#else + self.provider = MoyaProvider(plugins: [JwtPlugin()]) +#endif + } + + func request(_ api: API) -> Single { + return .create { single in + var disposables: [Disposable] = [] + if self.isApiNeedsAccessToken(api) { + disposables.append( + self.requestWithAccessToken(api) + .subscribe( + onSuccess: { single(.success($0)) }, + onFailure: { single(.failure($0)) } + ) + ) + } else { + disposables.append( + self.defaultRequest(api) + .subscribe( + onSuccess: { single(.success($0)) }, + onFailure: { single(.failure($0)) } + ) + ) + } + return Disposables.create(disposables) + } + } +} + +private extension BaseRemote { + 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) + } + if code == 401 && API.self != AuthAPI.self { + return self.reissueToken() + .andThen(.error(TokenError.expired)) + } + return .error( + api.errorMap?[code] ?? + JobisError.error( + message: (try? (error as? MoyaError)? + .response? + .mapJSON() as? NSDictionary)?["message"] as? String ?? "", + errorBody: [:] + ) + ) + } + } + + func requestWithAccessToken(_ api: API) -> Single { + return .deferred { + if self.checkTokenIsValid() { + return self.defaultRequest(api) + } else { + return .error(TokenError.expired) + } + } + .retry(when: { (errorObservable: Observable) in + return errorObservable + .flatMap { error -> Observable in + switch error { + case .expired: + return self.reissueToken() + .andThen(.just(())) + } + } + }) + } + + func isApiNeedsAccessToken(_ api: API) -> Bool { + return api.jwtTokenType == .accessToken + } + + func checkTokenIsValid() -> Bool { + let expired = keychainLocal.load(type: .accessExpiresAt).toJobisDate() + print(Date(), expired) + return Date() < expired + } + + func reissueToken() -> Completable { + return AuthRemoteImpl(keychainLocal: keychainLocal).reissueToken() + .asCompletable() + } +} diff --git a/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift new file mode 100644 index 00000000..506b8450 --- /dev/null +++ b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift @@ -0,0 +1,22 @@ +import RxSwift +import Domain + +final class AuthRepositoryImpl: AuthRepository { + private let authRemote: any AuthRemote + + init(authRemote: AuthRemote) { + self.authRemote = authRemote + } + + func sendAuthCode(req: SendAuthCodeRequestQuery) -> Completable { + authRemote.sendAuthCode(req: req) + } + + func reissueToken() -> Single { + authRemote.reissueToken() + } + + func verifyAuthCode(email: String, authCode: String) -> Completable { + authRemote.verifyAuthCode(email: email, authCode: authCode) + } +} diff --git a/Projects/Data/Sources/TempFile.swift b/Projects/Data/Sources/TempFile.swift deleted file mode 100644 index 8337712e..00000000 --- a/Projects/Data/Sources/TempFile.swift +++ /dev/null @@ -1 +0,0 @@ -// diff --git a/Projects/Data/Sources/Utils/String+toJobisDate.swift b/Projects/Data/Sources/Utils/String+toJobisDate.swift new file mode 100644 index 00000000..605bd55c --- /dev/null +++ b/Projects/Data/Sources/Utils/String+toJobisDate.swift @@ -0,0 +1,10 @@ +import Foundation + +public extension String { + func toJobisDate() -> Date { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + formatter.locale = Locale(identifier: "ko_kr") + return formatter.date(from: self) ?? .init() + } +} diff --git a/Projects/Domain/Sources/Entities/ReissueAuthorityEntity.swift b/Projects/Domain/Sources/Entities/ReissueAuthorityEntity.swift new file mode 100644 index 00000000..56b478c4 --- /dev/null +++ b/Projects/Domain/Sources/Entities/ReissueAuthorityEntity.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct ReissueAuthorityEntity: Equatable { + public let authority: AuthorityType + + public init(authority: AuthorityType) { + self.authority = authority + } +} diff --git a/Projects/Domain/Sources/Enums/AuthCodeType.swift b/Projects/Domain/Sources/Enums/AuthCodeType.swift new file mode 100644 index 00000000..3bf139d6 --- /dev/null +++ b/Projects/Domain/Sources/Enums/AuthCodeType.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum AuthCodeType: String, Encodable { + case password = "PASSWORD" + case signup = "SIGN_UP" +} diff --git a/Projects/Domain/Sources/Enums/AuthorityType.swift b/Projects/Domain/Sources/Enums/AuthorityType.swift new file mode 100644 index 00000000..8544afd7 --- /dev/null +++ b/Projects/Domain/Sources/Enums/AuthorityType.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum AuthorityType: String, Decodable { + case student = "STUDENT" + case company = "COMPANY" + case teacher = "TEACHTER" + case developer = "DEVELOPER" +} diff --git a/Projects/Domain/Sources/Error/JobisError.swift b/Projects/Domain/Sources/Error/JobisError.swift new file mode 100644 index 00000000..614ebd1d --- /dev/null +++ b/Projects/Domain/Sources/Error/JobisError.swift @@ -0,0 +1,18 @@ +import Foundation + +public enum JobisError: Error { + case error(message: String = "에러가 발생했습니다.", errorBody: [String: Any] = [:]) + case noInternet +} + +extension JobisError: LocalizedError { + public var errorDescription: String? { + switch self { + case let .error(message, _): + return message + + case .noInternet: + return "인터넷 연결이 원활하지 않습니다." + } + } +} diff --git a/Projects/Domain/Sources/Error/TokenError.swift b/Projects/Domain/Sources/Error/TokenError.swift new file mode 100644 index 00000000..49305b6b --- /dev/null +++ b/Projects/Domain/Sources/Error/TokenError.swift @@ -0,0 +1,3 @@ +public enum TokenError: Error { + case expired +} diff --git a/Projects/Domain/Sources/Parameters/SendAuthCodeRequestQuery.swift b/Projects/Domain/Sources/Parameters/SendAuthCodeRequestQuery.swift new file mode 100644 index 00000000..50b82684 --- /dev/null +++ b/Projects/Domain/Sources/Parameters/SendAuthCodeRequestQuery.swift @@ -0,0 +1,16 @@ +import Foundation + +public struct SendAuthCodeRequestQuery: Encodable { + public let email: String + public let authCodeType: AuthCodeType + + public init(email: String, authCodeType: AuthCodeType) { + self.email = email + self.authCodeType = authCodeType + } + + enum CodingKeys: String, CodingKey { + case email + case authCodeType = "auth_code_type" + } +} diff --git a/Projects/Domain/Sources/Repositories/AuthRepository.swift b/Projects/Domain/Sources/Repositories/AuthRepository.swift new file mode 100644 index 00000000..c6be8436 --- /dev/null +++ b/Projects/Domain/Sources/Repositories/AuthRepository.swift @@ -0,0 +1,7 @@ +import RxSwift + +public protocol AuthRepository { + func sendAuthCode(req: SendAuthCodeRequestQuery) -> Completable + func reissueToken() -> Single + func verifyAuthCode(email: String, authCode: String) -> Completable +} diff --git a/Projects/Domain/Sources/TempFile.swift b/Projects/Domain/Sources/TempFile.swift deleted file mode 100644 index 8337712e..00000000 --- a/Projects/Domain/Sources/TempFile.swift +++ /dev/null @@ -1 +0,0 @@ -// diff --git a/Projects/Domain/Sources/UseCases/Auth/ReissueTokenUaseCase.swift b/Projects/Domain/Sources/UseCases/Auth/ReissueTokenUaseCase.swift new file mode 100644 index 00000000..fb5ba0a3 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/Auth/ReissueTokenUaseCase.swift @@ -0,0 +1,13 @@ +import RxSwift + +public struct ReissueTokenUaseCase { + public init(authRepository: AuthRepository) { + self.authRepository = authRepository + } + + private let authRepository: AuthRepository + + public func execute() -> Single { + return authRepository.reissueToken() + } +} diff --git a/Projects/Domain/Sources/UseCases/Auth/SendAuthCodeUseCase.swift b/Projects/Domain/Sources/UseCases/Auth/SendAuthCodeUseCase.swift new file mode 100644 index 00000000..a49cf35d --- /dev/null +++ b/Projects/Domain/Sources/UseCases/Auth/SendAuthCodeUseCase.swift @@ -0,0 +1,13 @@ +import RxSwift + +public struct SendAuthCodeUseCase { + public init(authRepository: AuthRepository) { + self.authRepository = authRepository + } + + private let authRepository: AuthRepository + + public func execute(req: SendAuthCodeRequestQuery) -> Completable { + return authRepository.sendAuthCode(req: req) + } +} diff --git a/Projects/Domain/Sources/UseCases/Auth/VerifyAuthCodeUseCase.swift b/Projects/Domain/Sources/UseCases/Auth/VerifyAuthCodeUseCase.swift new file mode 100644 index 00000000..567e293d --- /dev/null +++ b/Projects/Domain/Sources/UseCases/Auth/VerifyAuthCodeUseCase.swift @@ -0,0 +1,13 @@ +import RxSwift + +public struct VerifyAuthCodeUseCase { + public init(authRepository: AuthRepository) { + self.authRepository = authRepository + } + + private let authRepository: AuthRepository + + public func execute(email: String, authCode: String) -> Completable { + return authRepository.verifyAuthCode(email: email, authCode: authCode) + } +} diff --git a/Projects/Flow/Sources/AppFlow.swift b/Projects/Flow/Sources/AppFlow.swift index fa0bad3f..3d7bc92f 100644 --- a/Projects/Flow/Sources/AppFlow.swift +++ b/Projects/Flow/Sources/AppFlow.swift @@ -10,12 +10,12 @@ public class AppFlow: Flow { return self.window } public var container: Container - + public init(window: UIWindow, container: Container) { self.window = window self.container = container } - + public func navigate(to step: Step) -> FlowContributors { guard let step = step as? AppStep else { return .none } switch step { @@ -29,6 +29,13 @@ public class AppFlow: Flow { Flows.use(mainFlow, when: .created) { (root) in self.window.rootViewController = root } - return .one(flowContributor: .contribute(withNextPresentable: mainFlow, withNextStepper: OneStepper(withSingleStep: MainStep.loginIsRequired))) + return .one( + flowContributor: .contribute( + withNextPresentable: mainFlow, + withNextStepper: OneStepper( + withSingleStep: MainStep.loginIsRequired + ) + ) + ) } } diff --git a/Projects/Flow/Sources/AppStepper.swift b/Projects/Flow/Sources/AppStepper.swift index fa4f664b..9a8bf1c7 100644 --- a/Projects/Flow/Sources/AppStepper.swift +++ b/Projects/Flow/Sources/AppStepper.swift @@ -14,5 +14,4 @@ public class AppStepper: Stepper { public var initialStep: Step { return AppStep.mainIsRequired } - } diff --git a/Projects/Flow/Sources/Main/MainFlow.swift b/Projects/Flow/Sources/Main/MainFlow.swift index b68e8fca..ff9898ce 100644 --- a/Projects/Flow/Sources/Main/MainFlow.swift +++ b/Projects/Flow/Sources/Main/MainFlow.swift @@ -6,27 +6,30 @@ import Core public class MainFlow: Flow { public var container: Container - + public var root: Presentable { return rootViewController } public init(container: Container) { self.container = container } - + private let rootViewController = UINavigationController() - + public func navigate(to step: RxFlow.Step) -> RxFlow.FlowContributors { guard let step = step as? MainStep else { return .none } - + switch step { case .loginIsRequired: return navigateToLoginScreen() } } - private func navigateToLoginScreen() -> FlowContributors { +} + +private extension MainFlow { + func navigateToLoginScreen() -> FlowContributors { let mainViewController = container.resolve(MainViewController.self)! - self.rootViewController.pushViewController(mainViewController, animated: true) + self.rootViewController.setViewControllers([mainViewController], animated: true) return .one(flowContributor: .contribute( withNextPresentable: mainViewController, withNextStepper: mainViewController.viewModel diff --git a/Projects/Modules/AppNetwork/Sources/JobisAPI.swift b/Projects/Modules/AppNetwork/Sources/JobisAPI.swift new file mode 100644 index 00000000..026e4dbc --- /dev/null +++ b/Projects/Modules/AppNetwork/Sources/JobisAPI.swift @@ -0,0 +1,55 @@ +import Foundation +import Moya + +public protocol JobisAPI: TargetType, JwtAuthorizable { + associatedtype ErrorType: Error + var domain: JobisDomain { get } + var urlPath: String { get } + var errorMap: [Int: ErrorType]? { get } +} + +public extension JobisAPI { + var baseURL: URL { + URL( + string: Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String ?? "" + ) ?? 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 JobisDomain: String { + case auth + case users + case recruitments + case companies + case students + case codes + case applications + case bookmarks + case reviews + case files + case bugs +} + +extension JobisDomain { + 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/Jwt/JwtAuthorizable.swift b/Projects/Modules/AppNetwork/Sources/Jwt/JwtAuthorizable.swift new file mode 100644 index 00000000..5cda8b9b --- /dev/null +++ b/Projects/Modules/AppNetwork/Sources/Jwt/JwtAuthorizable.swift @@ -0,0 +1,11 @@ +import Moya + +public enum JwtTokenType: String { + case accessToken = "Authorization" + case refreshToken = "X-Refresh-Token" + case none +} + +public protocol JwtAuthorizable { + var jwtTokenType: JwtTokenType { get } +} diff --git a/Projects/Modules/AppNetwork/Sources/Jwt/JwtPlugin.swift b/Projects/Modules/AppNetwork/Sources/Jwt/JwtPlugin.swift new file mode 100644 index 00000000..a90aeb8b --- /dev/null +++ b/Projects/Modules/AppNetwork/Sources/Jwt/JwtPlugin.swift @@ -0,0 +1,64 @@ +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: jwtTokenType == .accessToken ? .accessToken : .refreshToken))" + + 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 { + switch type { + case .accessToken: + return "Bearer \(keychain.load(type: .accessToken))" + + case .refreshToken: + return keychain.load(type: .refreshToken) + + case .accessExpiresAt: + return keychain.load(type: .accessExpiresAt) + + case .refreshExpiresAt: + return keychain.load(type: .refreshExpiresAt) + } + } + + func saveToken(token: TokenDTO) { + keychain.save(type: .accessToken, value: token.accessToken) + keychain.save(type: .refreshToken, value: token.refreshToken) + keychain.save(type: .accessExpiresAt, value: token.accessExpiresAt) + keychain.save(type: .refreshExpiresAt, value: token.refreshExpiresAt) + } +} diff --git a/Projects/Modules/AppNetwork/Sources/Jwt/Logging/MoyaLoggingPlugin.swift b/Projects/Modules/AppNetwork/Sources/Jwt/Logging/MoyaLoggingPlugin.swift new file mode 100644 index 00000000..3c424153 --- /dev/null +++ b/Projects/Modules/AppNetwork/Sources/Jwt/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/Jwt/TokenDTO.swift b/Projects/Modules/AppNetwork/Sources/Jwt/TokenDTO.swift new file mode 100644 index 00000000..f7e57d97 --- /dev/null +++ b/Projects/Modules/AppNetwork/Sources/Jwt/TokenDTO.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct TokenDTO: Equatable, Decodable { + let accessToken: String + let refreshToken: String + let accessExpiresAt: String + let refreshExpiresAt: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case accessExpiresAt = "access_expires_at" + case refreshExpiresAt = "refresh_expires_at" + } +} diff --git a/Projects/Modules/AppNetwork/Sources/TempFile.swift b/Projects/Modules/AppNetwork/Sources/TempFile.swift deleted file mode 100644 index d268021f..00000000 --- a/Projects/Modules/AppNetwork/Sources/TempFile.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// TempFile.swift -// ProjectDescriptionHelpers -// -// Created by 박주영 on 2023/09/26. -// - -import Foundation diff --git a/Projects/Modules/ThirdPartyLib/Project.swift b/Projects/Modules/ThirdPartyLib/Project.swift index ccf5853c..0b1f49d1 100644 --- a/Projects/Modules/ThirdPartyLib/Project.swift +++ b/Projects/Modules/ThirdPartyLib/Project.swift @@ -13,6 +13,8 @@ let project = Project.makeModule( .SPM.RxSwift, .SPM.SnapKit, .SPM.Then, - .SPM.Swinject + .SPM.Swinject, + .SPM.Moya, + .SPM.RxMoya ], sources: [] ) diff --git a/Projects/Presentation/Sources/DI/PresentationAssembly.swift b/Projects/Presentation/Sources/DI/PresentationAssembly.swift new file mode 100644 index 00000000..3c8e25ce --- /dev/null +++ b/Projects/Presentation/Sources/DI/PresentationAssembly.swift @@ -0,0 +1,17 @@ +import Foundation +import Swinject +import Core +import Domain + +public final class PresentationAssembly: Assembly { + public init() {} + + public func assemble(container: Container) { + container.register(MainViewModel.self) { resolver in + MainViewModel(usecase: resolver.resolve(SendAuthCodeUseCase.self)!) + } + container.register(MainViewController.self) { resolver in + MainViewController(resolver.resolve(MainViewModel.self)!) + } + } +} diff --git a/Projects/Presentation/Sources/Main/MainViewController.swift b/Projects/Presentation/Sources/Main/MainViewController.swift index 12da2e74..54608da7 100644 --- a/Projects/Presentation/Sources/Main/MainViewController.swift +++ b/Projects/Presentation/Sources/Main/MainViewController.swift @@ -10,20 +10,19 @@ public class MainViewController: BaseViewController { $0.setTitle("sdf", for: .normal) $0.setTitleColor(.black, for: .normal) } - + public override func layout() { button.snp.makeConstraints { $0.center.equalToSuperview() } } - + public override func addView() { self.view.addSubview(button) } - + public override func bind() { - let input = MainViewModel.Input(ButtonDidTap: button.rx.tap.asSignal()) + let input = MainViewModel.Input(buttonDidTap: button.rx.tap.asSignal()) _ = viewModel.transform(input) } - } diff --git a/Projects/Presentation/Sources/Main/MainViewModel.swift b/Projects/Presentation/Sources/Main/MainViewModel.swift index e7b8a930..699abb41 100644 --- a/Projects/Presentation/Sources/Main/MainViewModel.swift +++ b/Projects/Presentation/Sources/Main/MainViewModel.swift @@ -3,26 +3,37 @@ import RxSwift import RxCocoa import RxFlow import Core +import Domain public class MainViewModel: BaseViewModel, Stepper { public var steps = PublishRelay() - + private let disposeBag = DisposeBag() + + private let usecase: SendAuthCodeUseCase + + init(usecase: SendAuthCodeUseCase) { + self.usecase = usecase + } + public struct Input { - let ButtonDidTap: Signal + let buttonDidTap: Signal } - + public struct Output { let result: PublishRelay } public func transform(_ input: Input) -> Output { let result = PublishRelay() - input.ButtonDidTap.asObservable() - .map { MainStep.loginIsRequired } + input.buttonDidTap.asObservable() + .flatMap { [self] in + usecase.execute(req: .init(email: "gtw030488@gmail.com", authCodeType: .signup)) + .andThen(Single.just(MainStep.loginIsRequired)) + .catch { _ in .just(MainStep.loginIsRequired) } + } .bind(to: steps) .disposed(by: disposeBag) return Output(result: result) } - public init() { } } diff --git a/Scripts/SwiftLintRunScript.sh b/Scripts/SwiftLintRunScript.sh new file mode 100755 index 00000000..48e55fec --- /dev/null +++ b/Scripts/SwiftLintRunScript.sh @@ -0,0 +1,11 @@ +if test -d "/opt/homebrew/bin/"; then + PATH="/opt/homebrew/bin/:${PATH}" +fi + +export PATH + +if which swiftlint > /dev/null; then + swiftlint +else + echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint" +fi diff --git a/Tuist/Dependencies.swift b/Tuist/Dependencies.swift index aecc1973..c5ee5cc3 100644 --- a/Tuist/Dependencies.swift +++ b/Tuist/Dependencies.swift @@ -29,6 +29,10 @@ let dependencies = Dependencies( .remote( url: "https://github.com/Swinject/Swinject.git", requirement: .upToNextMajor(from: "2.8.3") + ), + .remote( + url: "https://github.com/Moya/Moya.git", + requirement: .upToNextMajor(from: "15.0.0") ) ]), platforms: [.iOS] diff --git a/Tuist/ProjectDescriptionHelpers/Action+Template.swift b/Tuist/ProjectDescriptionHelpers/Action+Template.swift new file mode 100644 index 00000000..d2aa5f87 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Action+Template.swift @@ -0,0 +1,9 @@ +import ProjectDescription + +public extension TargetScript { + static let swiftLint = TargetScript.pre( + path: Path.relativeToRoot("Scripts/SwiftLintRunScript.sh"), + name: "SwiftLint", + basedOnDependencyAnalysis: false + ) +} diff --git a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift index 1027273f..993251b9 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift @@ -48,8 +48,9 @@ public extension Project { bundleId: "\(env.organizationName).\(name)", deploymentTarget: deploymentTarget, infoPlist: infoPlist, - sources: sources, - resources: resources, + sources: sources, + resources: resources, + scripts: [.swiftLint], dependencies: dependencies ) diff --git a/graph.png b/graph.png index 4818bcd8..c1946a65 100644 Binary files a/graph.png and b/graph.png differ