diff --git a/Plugin/UtilityPlugin/ProjectDescriptionHelpers/Dependency+Project.swift b/Plugin/UtilityPlugin/ProjectDescriptionHelpers/Dependency+Project.swift index 8dc18a8e..e3bac5be 100644 --- a/Plugin/UtilityPlugin/ProjectDescriptionHelpers/Dependency+Project.swift +++ b/Plugin/UtilityPlugin/ProjectDescriptionHelpers/Dependency+Project.swift @@ -15,12 +15,15 @@ public extension TargetDependency.Project.Features { } public extension TargetDependency.Project.Module { + static let KeychainModule = TargetDependency.module(name: "KeychainModule") + static let ErrorModule = TargetDependency.module(name: "ErrorModule") static let FeatureThirdPartyLib = TargetDependency.module(name: "FeatureThirdPartyLib") static let ThirdPartyLib = TargetDependency.module(name: "ThirdPartyLib") static let Utility = TargetDependency.module(name: "Utility") } public extension TargetDependency.Project.Service { + static let DataMappingModule = TargetDependency.service(name: "DataMappingModule") static let APIKit = TargetDependency.service(name: "APIKit") static let Data = TargetDependency.service(name: "DataModule") static let Domain = TargetDependency.service(name: "DomainModule") diff --git a/Plugin/UtilityPlugin/ProjectDescriptionHelpers/Dependency+SPM.swift b/Plugin/UtilityPlugin/ProjectDescriptionHelpers/Dependency+SPM.swift index 36f555f9..3c2711d8 100644 --- a/Plugin/UtilityPlugin/ProjectDescriptionHelpers/Dependency+SPM.swift +++ b/Plugin/UtilityPlugin/ProjectDescriptionHelpers/Dependency+SPM.swift @@ -9,6 +9,8 @@ public extension TargetDependency.SPM { static let Quick = TargetDependency.external(name: "Quick") static let Nimble = TargetDependency.external(name: "Nimble") static let Needle = TargetDependency.external(name: "NeedleFoundation") + static let Moya = TargetDependency.external(name: "Moya") + static let CombineMoya = TargetDependency.external(name: "CombineMoya") } public extension Package { diff --git a/Projects/App/Sources/Application/DI/AppComponent+LocalDataSource.swift b/Projects/App/Sources/Application/DI/AppComponent+LocalDataSource.swift deleted file mode 100644 index d33c37bd..00000000 --- a/Projects/App/Sources/Application/DI/AppComponent+LocalDataSource.swift +++ /dev/null @@ -1,5 +0,0 @@ -import NeedleFoundation - -public extension AppComponent { - -} diff --git a/Projects/App/Sources/Application/DI/AppComponent+RemoteDataSource.swift b/Projects/App/Sources/Application/DI/AppComponent+RemoteDataSource.swift deleted file mode 100644 index d33c37bd..00000000 --- a/Projects/App/Sources/Application/DI/AppComponent+RemoteDataSource.swift +++ /dev/null @@ -1,5 +0,0 @@ -import NeedleFoundation - -public extension AppComponent { - -} diff --git a/Projects/App/Sources/Application/DI/AppComponent+Repository.swift b/Projects/App/Sources/Application/DI/AppComponent+Repository.swift deleted file mode 100644 index d33c37bd..00000000 --- a/Projects/App/Sources/Application/DI/AppComponent+Repository.swift +++ /dev/null @@ -1,5 +0,0 @@ -import NeedleFoundation - -public extension AppComponent { - -} diff --git a/Projects/App/Sources/Application/DI/AppComponent+UseCase.swift b/Projects/App/Sources/Application/DI/AppComponent+UseCase.swift deleted file mode 100644 index d33c37bd..00000000 --- a/Projects/App/Sources/Application/DI/AppComponent+UseCase.swift +++ /dev/null @@ -1,5 +0,0 @@ -import NeedleFoundation - -public extension AppComponent { - -} diff --git a/Projects/Features/BaseFeature/Sources/BaseViewModel.swift b/Projects/Features/BaseFeature/Sources/BaseViewModel.swift new file mode 100644 index 00000000..6ec871ff --- /dev/null +++ b/Projects/Features/BaseFeature/Sources/BaseViewModel.swift @@ -0,0 +1,33 @@ +import Combine +import ErrorModule + +open class BaseViewModel: ObservableObject { + @Published public var isErrorOcuured = false + @Published public var isLoading = false + @Published public var errorMessage = "" + + public var bag = Set() + + public init() {} + + public func addCancellable( + _ publisher: AnyPublisher, + onReceiveValue: @escaping (T) -> Void, + onReceiveError: ((DmsError) -> Void)? = nil + ) { + isLoading = true + publisher + .sink(receiveCompletion: { [weak self] completion in + if case let .failure(error) = completion { + if let onReceiveError { + onReceiveError(error.asDMSError) + } + + self?.errorMessage = error.localizedDescription + self?.isErrorOcuured = true + } + self?.isLoading = false + }, receiveValue: onReceiveValue) + .store(in: &bag) + } +} diff --git a/Projects/Features/BaseFeature/Sources/Feature.swift b/Projects/Features/BaseFeature/Sources/Feature.swift deleted file mode 100644 index 7d846fc7..00000000 --- a/Projects/Features/BaseFeature/Sources/Feature.swift +++ /dev/null @@ -1 +0,0 @@ -// this is for tuist diff --git a/Projects/Modules/ErrorModule/Derived/InfoPlists/ErrorModule-Info.plist b/Projects/Modules/ErrorModule/Derived/InfoPlists/ErrorModule-Info.plist new file mode 100644 index 00000000..323e5ecf --- /dev/null +++ b/Projects/Modules/ErrorModule/Derived/InfoPlists/ErrorModule-Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Projects/Modules/ErrorModule/Derived/InfoPlists/ErrorModuleTests-Info.plist b/Projects/Modules/ErrorModule/Derived/InfoPlists/ErrorModuleTests-Info.plist new file mode 100644 index 00000000..6c40a6cd --- /dev/null +++ b/Projects/Modules/ErrorModule/Derived/InfoPlists/ErrorModuleTests-Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Projects/Modules/ErrorModule/Project.swift b/Projects/Modules/ErrorModule/Project.swift new file mode 100644 index 00000000..87345b95 --- /dev/null +++ b/Projects/Modules/ErrorModule/Project.swift @@ -0,0 +1,7 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.makeModule( + name: "ErrorModule", + product: .staticFramework +) diff --git a/Projects/Modules/ErrorModule/Sources/DmsError.swift b/Projects/Modules/ErrorModule/Sources/DmsError.swift new file mode 100644 index 00000000..bb6af472 --- /dev/null +++ b/Projects/Modules/ErrorModule/Sources/DmsError.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum DmsError: Error { + case unknown + case custom(message: String = "알 수 없는 오류가 발생하였습니다", code: Int = 500) +} + +extension DmsError: LocalizedError { + public var errorDescription: String? { + switch self { + case .unknown: + return "알 수 없는 오류가 발생하였습니다" + + case let .custom(message, _): + return message + } + } +} + +public extension Error { + var asDMSError: DmsError { + self as? DmsError ?? .unknown + } +} diff --git a/Projects/Modules/ErrorModule/Tests/.gitkeep b/Projects/Modules/ErrorModule/Tests/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Services/APIKit/Tests/TargetTest.swift b/Projects/Modules/ErrorModule/Tests/TargetTest.swift similarity index 100% rename from Projects/Services/APIKit/Tests/TargetTest.swift rename to Projects/Modules/ErrorModule/Tests/TargetTest.swift diff --git a/Projects/Modules/KeychainModule/Derived/InfoPlists/KeychainModule-Info.plist b/Projects/Modules/KeychainModule/Derived/InfoPlists/KeychainModule-Info.plist new file mode 100644 index 00000000..323e5ecf --- /dev/null +++ b/Projects/Modules/KeychainModule/Derived/InfoPlists/KeychainModule-Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Projects/Modules/KeychainModule/Derived/InfoPlists/KeychainModuleTests-Info.plist b/Projects/Modules/KeychainModule/Derived/InfoPlists/KeychainModuleTests-Info.plist new file mode 100644 index 00000000..6c40a6cd --- /dev/null +++ b/Projects/Modules/KeychainModule/Derived/InfoPlists/KeychainModuleTests-Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Projects/Modules/KeychainModule/Project.swift b/Projects/Modules/KeychainModule/Project.swift new file mode 100644 index 00000000..58d5689d --- /dev/null +++ b/Projects/Modules/KeychainModule/Project.swift @@ -0,0 +1,7 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.makeModule( + name: "KeychainModule", + product: .staticFramework +) diff --git a/Projects/Modules/KeychainModule/Sources/Keychain.swift b/Projects/Modules/KeychainModule/Sources/Keychain.swift new file mode 100644 index 00000000..5b6589af --- /dev/null +++ b/Projects/Modules/KeychainModule/Sources/Keychain.swift @@ -0,0 +1,11 @@ +public enum KeychainType: String { + case accessToken = "ACCESS-TOKEN" + case refreshToken = "REFRESH-TOKEN" + case expiredAt = "EXPIRED-AT" +} + +public protocol Keychain { + func save(type: KeychainType, value: String) + func load(type: KeychainType) -> String + func delete(type: KeychainType) +} diff --git a/Projects/Modules/KeychainModule/Sources/KeychainFake.swift b/Projects/Modules/KeychainModule/Sources/KeychainFake.swift new file mode 100644 index 00000000..cda52e1f --- /dev/null +++ b/Projects/Modules/KeychainModule/Sources/KeychainFake.swift @@ -0,0 +1,17 @@ +import Foundation + +final class KeychainFake: Keychain { + var store: [String: String] = [:] + + func save(type: KeychainType, value: String) { + store[type.rawValue] = value + } + + func load(type: KeychainType) -> String { + store[type.rawValue] ?? "" + } + + func delete(type: KeychainType) { + store[type.rawValue] = nil + } +} diff --git a/Projects/Modules/KeychainModule/Sources/KeychainImpl.swift b/Projects/Modules/KeychainModule/Sources/KeychainImpl.swift new file mode 100644 index 00000000..ea2bf918 --- /dev/null +++ b/Projects/Modules/KeychainModule/Sources/KeychainImpl.swift @@ -0,0 +1,44 @@ +import Foundation + +public struct KeychainImpl: Keychain { + public init() {} + + private let service: String = Bundle.main.bundleIdentifier ?? "" + + public func save(type: KeychainType, value: String) { + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: type.rawValue, + kSecValueData: value.data(using: .utf8, allowLossyConversion: false) ?? .init() + ] + SecItemDelete(query) + SecItemAdd(query, nil) + } + + public func load(type: KeychainType) -> String { + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: type.rawValue, + kSecReturnData: kCFBooleanTrue!, + kSecMatchLimit: kSecMatchLimitOne + ] + var dataTypeRef: AnyObject? + let status = withUnsafeMutablePointer(to: &dataTypeRef) { SecItemCopyMatching(query, UnsafeMutablePointer($0)) } + 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, + kSecAttrService: service, + kSecAttrAccount: type.rawValue + ] + SecItemDelete(query) + } +} diff --git a/Projects/Modules/KeychainModule/Tests/.gitkeep b/Projects/Modules/KeychainModule/Tests/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Modules/KeychainModule/Tests/TargetTest.swift b/Projects/Modules/KeychainModule/Tests/TargetTest.swift new file mode 100644 index 00000000..b1cf7940 --- /dev/null +++ b/Projects/Modules/KeychainModule/Tests/TargetTest.swift @@ -0,0 +1,17 @@ +import XCTest + +class TargetTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + XCTAssertEqual("A", "A") + } + +} diff --git a/Projects/Modules/Utility/Project.swift b/Projects/Modules/Utility/Project.swift index 78482080..ed4ea86d 100644 --- a/Projects/Modules/Utility/Project.swift +++ b/Projects/Modules/Utility/Project.swift @@ -5,6 +5,7 @@ let project = Project.makeModule( name: "Utility", product: .staticFramework, dependencies: [ - .Project.Module.ThirdPartyLib + .Project.Module.ThirdPartyLib, + .Project.Module.ErrorModule ] ) diff --git a/Projects/Modules/Utility/Sources/DateUtil.swift b/Projects/Modules/Utility/Sources/DateUtil.swift new file mode 100644 index 00000000..feba72dc --- /dev/null +++ b/Projects/Modules/Utility/Sources/DateUtil.swift @@ -0,0 +1,19 @@ +import Foundation + +public extension String { + func toDMSDate() -> Date { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter.date(from: self) ?? .init() + } +} + +public extension Date { + func toDMSDateString() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + formatter.timeZone = .init(identifier: "UTC") + return formatter.string(from: self) + } +} diff --git a/Projects/Modules/Utility/Sources/Utility.swift b/Projects/Modules/Utility/Sources/Utility.swift deleted file mode 100644 index 8b137891..00000000 --- a/Projects/Modules/Utility/Sources/Utility.swift +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Projects/Services/APIKit/Project.swift b/Projects/Services/APIKit/Project.swift index 6043c319..ab4ef12f 100644 --- a/Projects/Services/APIKit/Project.swift +++ b/Projects/Services/APIKit/Project.swift @@ -5,6 +5,12 @@ let project = Project.makeModule( name: "APIKit", product: .staticFramework, dependencies: [ - .Project.Module.ThirdPartyLib + .Project.Module.ThirdPartyLib, + .Project.Module.KeychainModule, + .Project.Module.ErrorModule, + .Project.Service.DataMappingModule, + + .SPM.Moya, + .SPM.CombineMoya ] ) diff --git a/Projects/Services/APIKit/Sources/API.swift b/Projects/Services/APIKit/Sources/API.swift deleted file mode 100644 index 7d846fc7..00000000 --- a/Projects/Services/APIKit/Sources/API.swift +++ /dev/null @@ -1 +0,0 @@ -// this is for tuist diff --git a/Projects/Services/APIKit/Sources/DmsAPI.swift b/Projects/Services/APIKit/Sources/DmsAPI.swift new file mode 100644 index 00000000..8ddb22c6 --- /dev/null +++ b/Projects/Services/APIKit/Sources/DmsAPI.swift @@ -0,0 +1,38 @@ +import Foundation +import Moya +import ErrorModule + +public protocol DmsAPI: TargetType, JwtAuthorizable { + var domain: DmsDomain { get } + var urlPath: String { get } + var errorMap: [Int: DmsError] { get } +} + +public extension DmsAPI { + var baseURL: URL { + URL(string: "https://google.com")! // TODO: 서버의 배포가 완료되기 전에는 구글에게 몰래 요청을 날립니다 + } + + var path: String { + domain.asURLString + urlPath + } + + var headers: [String: String]? { + ["Content-Type": "application/json"] + } +} + +public enum DmsDomain: String { + case auth + case users + case losts + case notices + case meal + case images +} + +extension DmsDomain { + var asURLString: String { + "/\(self.rawValue)" + } +} diff --git a/Projects/Services/APIKit/Sources/Plugins/Jwt/JwtAuthorizable.swift b/Projects/Services/APIKit/Sources/Plugins/Jwt/JwtAuthorizable.swift new file mode 100644 index 00000000..fcdb412a --- /dev/null +++ b/Projects/Services/APIKit/Sources/Plugins/Jwt/JwtAuthorizable.swift @@ -0,0 +1,11 @@ +import Moya + +public enum JwtTokenType: String { + case accessToken = "Authorization" + case refreshToken = "refresh-token" + case none +} + +public protocol JwtAuthorizable { + var jwtTokenType: JwtTokenType { get } +} diff --git a/Projects/Services/APIKit/Sources/Plugins/Jwt/JwtPlugin.swift b/Projects/Services/APIKit/Sources/Plugins/Jwt/JwtPlugin.swift new file mode 100644 index 00000000..0af2a5a1 --- /dev/null +++ b/Projects/Services/APIKit/Sources/Plugins/Jwt/JwtPlugin.swift @@ -0,0 +1,60 @@ +import Moya +import KeychainModule +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 = "Bearer \(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 keychain.load(type: .accessToken) + + case .refreshToken: + return keychain.load(type: .refreshToken) + + case .expiredAt: + return keychain.load(type: .expiredAt) + } + } + + func saveToken(token: TokenDTO) { + keychain.save(type: .accessToken, value: token.accessToken) + keychain.save(type: .refreshToken, value: token.refreshToken) + keychain.save(type: .expiredAt, value: token.expiredAt) + } +} diff --git a/Projects/Services/APIKit/Sources/Plugins/Jwt/TokenDTO.swift b/Projects/Services/APIKit/Sources/Plugins/Jwt/TokenDTO.swift new file mode 100644 index 00000000..043e13d1 --- /dev/null +++ b/Projects/Services/APIKit/Sources/Plugins/Jwt/TokenDTO.swift @@ -0,0 +1,13 @@ +import Foundation + +struct TokenDTO: Equatable, Decodable { + let accessToken: String + let refreshToken: String + let expiredAt: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiredAt = "expired_at" + } +} diff --git a/Projects/Services/APIKit/Tests/JwtPluginSpec.swift b/Projects/Services/APIKit/Tests/JwtPluginSpec.swift new file mode 100644 index 00000000..e1796f8f --- /dev/null +++ b/Projects/Services/APIKit/Tests/JwtPluginSpec.swift @@ -0,0 +1,94 @@ +import Quick +import Nimble +import Moya +import Foundation +@testable import APIKit +@testable import KeychainModule + +// swiftlint: disable function_body_length +final class JwtPluginSpec: QuickSpec { + override func spec() { + var keychain: Keychain! + var plugin: JwtPlugin! + var api: MoyaProvider! + + beforeEach { + keychain = KeychainFake() + plugin = JwtPlugin(keychain: keychain) + let customEnpointClosure = { (target: TestAPI) -> Endpoint in + Endpoint( + url: URL(target: target).absoluteString, + sampleResponseClosure: { .networkResponse(200, target.sampleData) }, + method: target.method, + task: target.task, + httpHeaderFields: target.headers + ) + } + api = MoyaProvider( + endpointClosure: customEnpointClosure, + stubClosure: MoyaProvider.immediatelyStub, + plugins: [plugin] + ) + } + + describe("JwtPlugin을 사용하는 API는") { + afterEach { + keychain.delete(type: .accessToken) + keychain.delete(type: .refreshToken) + keychain.delete(type: .expiredAt) + } + context("Response가 TokenDTO의 데이터 타입으로 온다면") { + it("Keychain에 Token을 저장한다") { + api.request(.success) { _ in } + expect(keychain.load(type: .accessToken)).to(equal("access")) + expect(keychain.load(type: .refreshToken)).to(equal("refresh")) + expect(keychain.load(type: .expiredAt)).to(equal("expired")) + } + } + context("Response가 TokenDTO의 데이터 타입으로 오지 않는다면") { + it("Keychain에 아무 데이터도 저장하지 않는다") { + api.request(.failure) { _ in } + expect(keychain.load(type: .accessToken)).to(beEmpty()) + expect(keychain.load(type: .refreshToken)).to(beEmpty()) + expect(keychain.load(type: .expiredAt)).to(beEmpty()) + } + } + context("Enpoint의 JwtAuthorizable인 .accessToken인 API요청을 한다면") { + beforeEach { + keychain.save(type: .accessToken, value: "Access") + } + it("HTTPHeader의 Authorization에, 앞에 Bearer와 함께 AccessToken키체인에 저장된 값이 자동으로 담긴다") { + api.request(.withAccess) { result in + switch result { + case .failure: + fail("요청이 실패함") + + case let .success(res): + expect(res.request?.allHTTPHeaderFields?["Authorization"]).toNot(beNil()) + expect(res.request?.allHTTPHeaderFields?["Authorization"]).to(equal("Bearer Access")) + expect(res.statusCode).to(equal(200)) + } + } + } + } + context("Enpoint의 JwtAuthorizable인 .refreshToken인 API요청을 한다면") { + beforeEach { + keychain.save(type: .refreshToken, value: "Refresh") + } + it("HTTPHeader의 refresh-token에, 앞에 Bearer와 함께 RefreshToken키체인에 저장된 값이 자동으로 담긴다") { + api.request(.withRefresh) { result in + switch result { + case .failure: + fail("요청이 실패함") + + case let .success(res): + expect(res.request?.allHTTPHeaderFields?["refresh-token"]).toNot(beNil()) + expect(res.request?.allHTTPHeaderFields?["refresh-token"]).to(equal("Bearer Refresh")) + expect(res.statusCode).to(equal(200)) + } + } + } + } + } + } +} diff --git a/Projects/Services/APIKit/Tests/TestAPI.swift b/Projects/Services/APIKit/Tests/TestAPI.swift new file mode 100644 index 00000000..9f5518ba --- /dev/null +++ b/Projects/Services/APIKit/Tests/TestAPI.swift @@ -0,0 +1,57 @@ +import Moya +import Foundation +import APIKit + +public enum TestAPI: TargetType, JwtAuthorizable { + case success + case failure + case withAccess + case withRefresh + + public var baseURL: URL { URL(string: "localhost")! } + + public var path: String { "/" } + + public var method: Moya.Method { .get } + + public var sampleData: Data { + switch self { + case .success: + return """ +{ + "access_token": "access", + "refresh_token": "refresh", + "expired_at": "expired" +} +""".data(using: .utf8)! + + case .failure: + return """ +{ + "test": "how", + "access_token": "access" +} +""".data(using: .utf8)! + + default: + return .init() + } + } + + public var task: Moya.Task { .requestPlain } + + public var headers: [String: String]? { nil } + + public var jwtTokenType: JwtTokenType { + switch self { + case .withAccess: + return .accessToken + + case .withRefresh: + return .refreshToken + + default: + return .none + } + } +} diff --git a/Projects/Services/DataMappingModule/Derived/InfoPlists/DataMappingModule-Info.plist b/Projects/Services/DataMappingModule/Derived/InfoPlists/DataMappingModule-Info.plist new file mode 100644 index 00000000..323e5ecf --- /dev/null +++ b/Projects/Services/DataMappingModule/Derived/InfoPlists/DataMappingModule-Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Projects/Services/DataMappingModule/Derived/InfoPlists/DataMappingModuleTests-Info.plist b/Projects/Services/DataMappingModule/Derived/InfoPlists/DataMappingModuleTests-Info.plist new file mode 100644 index 00000000..6c40a6cd --- /dev/null +++ b/Projects/Services/DataMappingModule/Derived/InfoPlists/DataMappingModuleTests-Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Projects/Services/DataMappingModule/Project.swift b/Projects/Services/DataMappingModule/Project.swift new file mode 100644 index 00000000..2b926c25 --- /dev/null +++ b/Projects/Services/DataMappingModule/Project.swift @@ -0,0 +1,7 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.makeModule( + name: "DataMappingModule", + product: .staticFramework +) diff --git a/Projects/Services/DataMappingModule/Sources/Base/NoResponse.swift b/Projects/Services/DataMappingModule/Sources/Base/NoResponse.swift new file mode 100644 index 00000000..c9f10c41 --- /dev/null +++ b/Projects/Services/DataMappingModule/Sources/Base/NoResponse.swift @@ -0,0 +1,3 @@ +import Foundation + +public struct NoResponse: Codable {} diff --git a/Projects/Services/DataMappingModule/Tests/.gitkeep b/Projects/Services/DataMappingModule/Tests/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Services/DataMappingModule/Tests/TargetTest.swift b/Projects/Services/DataMappingModule/Tests/TargetTest.swift new file mode 100644 index 00000000..b1cf7940 --- /dev/null +++ b/Projects/Services/DataMappingModule/Tests/TargetTest.swift @@ -0,0 +1,17 @@ +import XCTest + +class TargetTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + XCTAssertEqual("A", "A") + } + +} diff --git a/Projects/Services/DomainModule/Project.swift b/Projects/Services/DomainModule/Project.swift index 22d768c9..5956fb29 100644 --- a/Projects/Services/DomainModule/Project.swift +++ b/Projects/Services/DomainModule/Project.swift @@ -5,6 +5,7 @@ let project = Project.makeModule( name: "DomainModule", product: .staticFramework, dependencies: [ - .Project.Module.ThirdPartyLib + .Project.Module.ThirdPartyLib, + .Project.Service.DataMappingModule ] ) diff --git a/Projects/Services/NetworkModule/Sources/Base/BaseRemoteDataSource.swift b/Projects/Services/NetworkModule/Sources/Base/BaseRemoteDataSource.swift new file mode 100644 index 00000000..a7e19880 --- /dev/null +++ b/Projects/Services/NetworkModule/Sources/Base/BaseRemoteDataSource.swift @@ -0,0 +1,80 @@ +import APIKit +import Foundation +import KeychainModule +import Moya +import Utility + +public class BaseRemoteDataSource { + private let keychain: any Keychain + private let provider: MoyaProvider + private let decoder = JSONDecoder() + private let maxRetryCount = 2 + + public init( + keychain: any Keychain, + provider: MoyaProvider? = nil + ) { + self.keychain = keychain + + #if DEBUG + self.provider = provider ?? MoyaProvider(plugins: [JwtPlugin(keychain: keychain), NetworkLoggerPlugin()]) + #else + self.provider = provider ?? MoyaProvider(plugins: [JwtPlugin(keychain: keychain)]) + #endif + } +} + +private extension BaseRemoteDataSource { + func defaultRequest(_ api: API) async throws -> Response { + for _ in 0.. Response { + for _ in 0.. Response { + try await withCheckedThrowingContinuation { config in + provider.request(api) { result in + switch result { + case let .success(res): + config.resume(returning: res) + + case let .failure(err): + let code = err.response?.statusCode ?? 500 + config.resume( + throwing: api.errorMap[code] ?? .custom() + ) + } + } + } + } + + func checkIsApiNeedsAuth(_ api: API) -> Bool { + api.jwtTokenType == .accessToken + } + + func checkTokenIsExpired() -> Bool { + let expired = keychain.load(type: .expiredAt).toDMSDate() + return Date() > expired + } + + func tokenReissue() async throws { + // TODO: Token Refresh + } +} diff --git a/Projects/Services/NetworkModule/Sources/Feature.swift b/Projects/Services/NetworkModule/Sources/Feature.swift deleted file mode 100644 index 8d35d5c8..00000000 --- a/Projects/Services/NetworkModule/Sources/Feature.swift +++ /dev/null @@ -1 +0,0 @@ -// This is for the feature diff --git a/Tuist/Dependencies.swift b/Tuist/Dependencies.swift index 357e937a..0aeb3c4f 100644 --- a/Tuist/Dependencies.swift +++ b/Tuist/Dependencies.swift @@ -5,7 +5,8 @@ let dependencies = Dependencies( swiftPackageManager: [ .remote(url: "https://github.com/Quick/Quick.git", requirement: .upToNextMajor(from: "5.0.1")), .remote(url: "https://github.com/Quick/Nimble.git", requirement: .upToNextMajor(from: "10.0.0")), - .remote(url: "https://github.com/uber/needle.git", requirement: .upToNextMajor(from: "0.19.0")) + .remote(url: "https://github.com/uber/needle.git", requirement: .upToNextMajor(from: "0.19.0")), + .remote(url: "https://github.com/Moya/Moya.git", requirement: .upToNextMajor(from: "15.0.3")) ], platforms: [.iOS] ) diff --git a/graph.png b/graph.png new file mode 100644 index 00000000..a9edf932 Binary files /dev/null and b/graph.png differ