diff --git a/AutoCat2.xcodeproj/project.pbxproj b/AutoCat2.xcodeproj/project.pbxproj index 91a9e80..5f6e1a4 100644 --- a/AutoCat2.xcodeproj/project.pbxproj +++ b/AutoCat2.xcodeproj/project.pbxproj @@ -36,10 +36,23 @@ 7A40D60926998DCF009B0BC4 /* Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A40D60726998DCF009B0BC4 /* Alert.swift */; }; 7A40D60C2699A070009B0BC4 /* MockURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A40D60B2699A070009B0BC4 /* MockURLProtocol.swift */; }; 7A503C03269F382F002C1A0D /* login_success.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A503C02269F382F002C1A0D /* login_success.json */; }; - 7A503C05269F494C002C1A0D /* login_invalid_params.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A503C04269F494C002C1A0D /* login_invalid_params.json */; }; - 7A503C07269F49F4002C1A0D /* login_wrong_credentials.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A503C06269F49F4002C1A0D /* login_wrong_credentials.json */; }; 7A683999269612EA00B2188A /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A683998269612EA00B2188A /* Response.swift */; }; 7A68399A269612EA00B2188A /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A683998269612EA00B2188A /* Response.swift */; }; + 7A971F0626AD6F2F007E527B /* ApiMethodMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F0526AD6F2F007E527B /* ApiMethodMock.swift */; }; + 7A971F0826AD7084007E527B /* LoginMethodMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F0726AD7084007E527B /* LoginMethodMock.swift */; }; + 7A971F0A26AD74FD007E527B /* ApiMethodMockProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F0926AD74FD007E527B /* ApiMethodMockProtocol.swift */; }; + 7A971F0D26AD7D4C007E527B /* AnyEncodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F0C26AD7D4C007E527B /* AnyEncodable.swift */; }; + 7A971F0E26AD7D4C007E527B /* AnyEncodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F0C26AD7D4C007E527B /* AnyEncodable.swift */; }; + 7A971F1526AD8AEB007E527B /* Initialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F1026AD8AEB007E527B /* Initialization.swift */; }; + 7A971F1626AD8AEB007E527B /* Initialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F1026AD8AEB007E527B /* Initialization.swift */; }; + 7A971F1726AD8AEB007E527B /* Querying.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F1126AD8AEB007E527B /* Querying.swift */; }; + 7A971F1826AD8AEB007E527B /* Querying.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F1126AD8AEB007E527B /* Querying.swift */; }; + 7A971F1926AD8AEB007E527B /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F1226AD8AEB007E527B /* JSON.swift */; }; + 7A971F1A26AD8AEB007E527B /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F1226AD8AEB007E527B /* JSON.swift */; }; + 7A971F1D26AD8AEB007E527B /* Merging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F1426AD8AEB007E527B /* Merging.swift */; }; + 7A971F1E26AD8AEB007E527B /* Merging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F1426AD8AEB007E527B /* Merging.swift */; }; + 7A971F2026ADC351007E527B /* ApiError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F1F26ADC351007E527B /* ApiError.swift */; }; + 7A971F2126ADC351007E527B /* ApiError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A971F1F26ADC351007E527B /* ApiError.swift */; }; 7ACD05D72695C08A00557667 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ACD05D62695C08A00557667 /* Constants.swift */; }; 7ACD05D82695C08A00557667 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ACD05D62695C08A00557667 /* Constants.swift */; }; 7AEFAEED26985A3400ED2C85 /* ACProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AEFAEEC26985A3400ED2C85 /* ACProgressView.swift */; }; @@ -117,9 +130,16 @@ 7A40D60726998DCF009B0BC4 /* Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alert.swift; sourceTree = ""; }; 7A40D60B2699A070009B0BC4 /* MockURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLProtocol.swift; sourceTree = ""; }; 7A503C02269F382F002C1A0D /* login_success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = login_success.json; sourceTree = ""; }; - 7A503C04269F494C002C1A0D /* login_invalid_params.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = login_invalid_params.json; sourceTree = ""; }; - 7A503C06269F49F4002C1A0D /* login_wrong_credentials.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = login_wrong_credentials.json; sourceTree = ""; }; 7A683998269612EA00B2188A /* Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Response.swift; sourceTree = ""; }; + 7A971F0526AD6F2F007E527B /* ApiMethodMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiMethodMock.swift; sourceTree = ""; }; + 7A971F0726AD7084007E527B /* LoginMethodMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginMethodMock.swift; sourceTree = ""; }; + 7A971F0926AD74FD007E527B /* ApiMethodMockProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiMethodMockProtocol.swift; sourceTree = ""; }; + 7A971F0C26AD7D4C007E527B /* AnyEncodable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyEncodable.swift; sourceTree = ""; }; + 7A971F1026AD8AEB007E527B /* Initialization.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Initialization.swift; sourceTree = ""; }; + 7A971F1126AD8AEB007E527B /* Querying.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Querying.swift; sourceTree = ""; }; + 7A971F1226AD8AEB007E527B /* JSON.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; + 7A971F1426AD8AEB007E527B /* Merging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Merging.swift; sourceTree = ""; }; + 7A971F1F26ADC351007E527B /* ApiError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiError.swift; sourceTree = ""; }; 7ACD05D62695C08A00557667 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 7AEFAEEC26985A3400ED2C85 /* ACProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACProgressView.swift; sourceTree = ""; }; 7AF552D82696E5C100578083 /* ApiTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiTests.swift; sourceTree = ""; }; @@ -179,6 +199,7 @@ 7A40D5782691C6D7009B0BC4 /* Shared */ = { isa = PBXGroup; children = ( + 7A971F0B26AD7D27007E527B /* ThirdParty */, 7A40D6002694FF4C009B0BC4 /* Utils */, 7A40D5FC2693A90F009B0BC4 /* Extensions */, 7A40D5EB2693A1C3009B0BC4 /* ViewModels */, @@ -266,8 +287,7 @@ 7A40D5F42693A63A009B0BC4 /* AutoCat2Tests */ = { isa = PBXGroup; children = ( - 7A503C00269F370A002C1A0D /* Responses */, - 7A40D60A2699A04F009B0BC4 /* Mocks */, + 7A971F0326AD6EA1007E527B /* Api */, 7A40D5F52693A63A009B0BC4 /* SettingsTests.swift */, 7AF552D82696E5C100578083 /* ApiTests.swift */, ); @@ -287,6 +307,7 @@ isa = PBXGroup; children = ( 7A40D6012694FF5D009B0BC4 /* Api.swift */, + 7A971F1F26ADC351007E527B /* ApiError.swift */, 7ACD05D62695C08A00557667 /* Constants.swift */, ); path = Utils; @@ -295,7 +316,8 @@ 7A40D60A2699A04F009B0BC4 /* Mocks */ = { isa = PBXGroup; children = ( - 7A40D60B2699A070009B0BC4 /* MockURLProtocol.swift */, + 7A971F0526AD6F2F007E527B /* ApiMethodMock.swift */, + 7A971F0726AD7084007E527B /* LoginMethodMock.swift */, ); path = Mocks; sourceTree = ""; @@ -311,13 +333,50 @@ 7A503C01269F3797002C1A0D /* Login */ = { isa = PBXGroup; children = ( - 7A503C06269F49F4002C1A0D /* login_wrong_credentials.json */, - 7A503C04269F494C002C1A0D /* login_invalid_params.json */, 7A503C02269F382F002C1A0D /* login_success.json */, ); path = Login; sourceTree = ""; }; + 7A971F0326AD6EA1007E527B /* Api */ = { + isa = PBXGroup; + children = ( + 7A971F0426AD6ED6007E527B /* Lib */, + 7A40D60A2699A04F009B0BC4 /* Mocks */, + 7A503C00269F370A002C1A0D /* Responses */, + ); + path = Api; + sourceTree = ""; + }; + 7A971F0426AD6ED6007E527B /* Lib */ = { + isa = PBXGroup; + children = ( + 7A40D60B2699A070009B0BC4 /* MockURLProtocol.swift */, + 7A971F0926AD74FD007E527B /* ApiMethodMockProtocol.swift */, + ); + path = Lib; + sourceTree = ""; + }; + 7A971F0B26AD7D27007E527B /* ThirdParty */ = { + isa = PBXGroup; + children = ( + 7A971F0F26AD8AEB007E527B /* GenericJSON */, + 7A971F0C26AD7D4C007E527B /* AnyEncodable.swift */, + ); + path = ThirdParty; + sourceTree = ""; + }; + 7A971F0F26AD8AEB007E527B /* GenericJSON */ = { + isa = PBXGroup; + children = ( + 7A971F1026AD8AEB007E527B /* Initialization.swift */, + 7A971F1126AD8AEB007E527B /* Querying.swift */, + 7A971F1226AD8AEB007E527B /* JSON.swift */, + 7A971F1426AD8AEB007E527B /* Merging.swift */, + ); + path = GenericJSON; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -498,9 +557,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 7A503C07269F49F4002C1A0D /* login_wrong_credentials.json in Resources */, 7A503C03269F382F002C1A0D /* login_success.json in Resources */, - 7A503C05269F494C002C1A0D /* login_invalid_params.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -512,13 +569,19 @@ buildActionMask = 2147483647; files = ( 7A40D5E926938BEC009B0BC4 /* AuthView.swift in Sources */, + 7A971F0D26AD7D4C007E527B /* AnyEncodable.swift in Sources */, 7ACD05D72695C08A00557667 /* Constants.swift in Sources */, + 7A971F1926AD8AEB007E527B /* JSON.swift in Sources */, + 7A971F1D26AD8AEB007E527B /* Merging.swift in Sources */, 7A40D5E326924B09009B0BC4 /* Settings.swift in Sources */, 7AEFAEED26985A3400ED2C85 /* ACProgressView.swift in Sources */, 7A40D5A02691C6D8009B0BC4 /* AutoCat2App.swift in Sources */, 7A683999269612EA00B2188A /* Response.swift in Sources */, + 7A971F1526AD8AEB007E527B /* Initialization.swift in Sources */, + 7A971F1726AD8AEB007E527B /* Querying.swift in Sources */, 7A40D60826998DCF009B0BC4 /* Alert.swift in Sources */, 7A40D5A42691C6D8009B0BC4 /* Persistence.swift in Sources */, + 7A971F2026ADC351007E527B /* ApiError.swift in Sources */, 7A40D5ED2693A1EA009B0BC4 /* AuthVM.swift in Sources */, 7A40D59E2691C6D8009B0BC4 /* AutoCat2.xcdatamodeld in Sources */, 7A40D5E126924AEC009B0BC4 /* User.swift in Sources */, @@ -533,13 +596,19 @@ buildActionMask = 2147483647; files = ( 7A40D5EA26938BEC009B0BC4 /* AuthView.swift in Sources */, + 7A971F0E26AD7D4C007E527B /* AnyEncodable.swift in Sources */, 7ACD05D82695C08A00557667 /* Constants.swift in Sources */, + 7A971F1A26AD8AEB007E527B /* JSON.swift in Sources */, + 7A971F1E26AD8AEB007E527B /* Merging.swift in Sources */, 7A40D5A12691C6D8009B0BC4 /* AutoCat2App.swift in Sources */, 7AEFAEEE26985A3400ED2C85 /* ACProgressView.swift in Sources */, 7A40D5A52691C6D8009B0BC4 /* Persistence.swift in Sources */, 7A68399A269612EA00B2188A /* Response.swift in Sources */, + 7A971F1626AD8AEB007E527B /* Initialization.swift in Sources */, + 7A971F1826AD8AEB007E527B /* Querying.swift in Sources */, 7A40D60926998DCF009B0BC4 /* Alert.swift in Sources */, 7A40D5E526924B0C009B0BC4 /* User.swift in Sources */, + 7A971F2126ADC351007E527B /* ApiError.swift in Sources */, 7A40D5EE2693A1EA009B0BC4 /* AuthVM.swift in Sources */, 7A40D59F2691C6D8009B0BC4 /* AutoCat2.xcdatamodeld in Sources */, 7A40D5A32691C6D8009B0BC4 /* ContentView.swift in Sources */, @@ -570,8 +639,11 @@ buildActionMask = 2147483647; files = ( 7A40D60C2699A070009B0BC4 /* MockURLProtocol.swift in Sources */, + 7A971F0826AD7084007E527B /* LoginMethodMock.swift in Sources */, 7AF552D92696E5C100578083 /* ApiTests.swift in Sources */, + 7A971F0A26AD74FD007E527B /* ApiMethodMockProtocol.swift in Sources */, 7A40D5F62693A63A009B0BC4 /* SettingsTests.swift in Sources */, + 7A971F0626AD6F2F007E527B /* ApiMethodMock.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/AutoCat2.xcodeproj/xcuserdata/selim.xcuserdatad/xcschemes/xcschememanagement.plist b/AutoCat2.xcodeproj/xcuserdata/selim.xcuserdatad/xcschemes/xcschememanagement.plist index 65d954e..6d7bf61 100644 --- a/AutoCat2.xcodeproj/xcuserdata/selim.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/AutoCat2.xcodeproj/xcuserdata/selim.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,12 @@ AutoCat2 (iOS).xcscheme_^#shared#^_ orderHint - 1 + 0 AutoCat2 (macOS).xcscheme_^#shared#^_ orderHint - 0 + 1 AutoCatCore.xcscheme_^#shared#^_ diff --git a/AutoCat2Tests/Api/Lib/ApiMethodMockProtocol.swift b/AutoCat2Tests/Api/Lib/ApiMethodMockProtocol.swift new file mode 100644 index 0000000..bf45947 --- /dev/null +++ b/AutoCat2Tests/Api/Lib/ApiMethodMockProtocol.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol ApiMethodMockProtocol { + var path: String { get } + var httpMethod: String { get } + func response(headers: [String: String], params: [String: Any]) -> (status: Int, data: Data?) +} diff --git a/AutoCat2Tests/Api/Lib/MockURLProtocol.swift b/AutoCat2Tests/Api/Lib/MockURLProtocol.swift new file mode 100644 index 0000000..b60320c --- /dev/null +++ b/AutoCat2Tests/Api/Lib/MockURLProtocol.swift @@ -0,0 +1,96 @@ +import Foundation + +extension URL { + public var queryParameters: [String: String]? { + guard + let components = URLComponents(url: self, resolvingAgainstBaseURL: true), + let queryItems = components.queryItems else { return nil } + return queryItems.reduce(into: [String: String]()) { (result, item) in + result[item.name] = item.value + } + } +} + +extension URLRequest { + func bodySteamAsJSON() -> [String: Any]? { + guard let bodyStream = self.httpBodyStream else { return nil } + + bodyStream.open() + + // Will read 16 chars per iteration. Can use bigger buffer if needed + let bufferSize: Int = 16 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + var dat = Data() + + while bodyStream.hasBytesAvailable { + let readDat = bodyStream.read(buffer, maxLength: bufferSize) + dat.append(buffer, count: readDat) + } + + buffer.deallocate() + bodyStream.close() + + return try? JSONSerialization.jsonObject(with: dat, options: JSONSerialization.ReadingOptions.allowFragments) as? [String: Any] + } +} + +class MockURLProtocol: URLProtocol { + + static var baseUrl: String = "" + static var apiMethodMocks: [ApiMethodMockProtocol] = [] + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + guard let requestUrl = request.url else { return } + + let methodMock = MockURLProtocol.apiMethodMocks.first { + return request.url?.absoluteString == MockURLProtocol.baseUrl + $0.path + && request.httpMethod == $0.httpMethod + } + + guard let methodMock = methodMock else { + if let response = HTTPURLResponse(url: requestUrl, statusCode: 404, httpVersion: "HTTP/2", headerFields: [:]) { + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocolDidFinishLoading(self) + } + + return + } + + // Assuming we use url parameters in GET requests and JSON-encoded body in everything else + var params: [String: Any] = [:] + if request.httpBodyStream != nil { + if let bodyDict = request.bodySteamAsJSON() { + params = bodyDict + } + } else { + if let urlParams = requestUrl.queryParameters { + params = urlParams + } + } + + let result = methodMock.response(headers: request.allHTTPHeaderFields ?? [:], params: params) + guard let response = HTTPURLResponse(url: requestUrl, statusCode: result.status, httpVersion: "HTTP/2", headerFields: [:]) else { return } + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + + if let data = result.data { + client?.urlProtocol(self, didLoad: data) + } + + client?.urlProtocolDidFinishLoading(self) + + //client?.urlProtocol(self, didFailWithError: error) + } + + override func stopLoading() { + + } +} diff --git a/AutoCat2Tests/Api/Mocks/ApiMethodMock.swift b/AutoCat2Tests/Api/Mocks/ApiMethodMock.swift new file mode 100644 index 0000000..f4e82ff --- /dev/null +++ b/AutoCat2Tests/Api/Mocks/ApiMethodMock.swift @@ -0,0 +1,39 @@ +import Foundation +import AutoCat2 + +open class ApiMethodMock: ApiMethodMockProtocol { + + private(set) var path: String + private(set) var httpMethod: String + + init(httpMethod: String, path: String) { + self.httpMethod = httpMethod + self.path = path + } + + func readData(from path: String) -> Data? { + guard let url = Bundle(for: type(of: self)).url(forResource: path, withExtension: "json") else { return nil } + return try? Data(contentsOf: url) + } + + func error(message: String, code: ApiErrorCode? = nil) -> Data? { + var errorData: [String: AnyEncodable] = [ + "success": false, + "error": AnyEncodable(message) + ] + + if let code = code { + errorData["errorCode"] = AnyEncodable(code) + } + + return try? JSONEncoder().encode(errorData) + } + + func notFoundResponse() -> (status: Int, data: Data?) { + return (status: 404, data: self.error(message: "Not found")) + } + + open func response(headers: [String : String], params: [String : Any]) -> (status: Int, data: Data?) { + return self.notFoundResponse() + } +} diff --git a/AutoCat2Tests/Api/Mocks/LoginMethodMock.swift b/AutoCat2Tests/Api/Mocks/LoginMethodMock.swift new file mode 100644 index 0000000..837f49b --- /dev/null +++ b/AutoCat2Tests/Api/Mocks/LoginMethodMock.swift @@ -0,0 +1,24 @@ +import Foundation + +class LoginMethodMock: ApiMethodMock { + private var login: String + private var password: String + + init(httpMethod: String, path: String, login: String, password: String) { + self.login = login + self.password = password + super.init(httpMethod: httpMethod, path: path) + } + + override func response(headers: [String : String], params: [String : Any]) -> (status: Int, data: Data?) { + guard let login = params["email"] as? String, let password = params["password"] as? String else { + return (status: 400, data: self.error(message: "Invalid parameters")) + } + + if login != self.login || password != self.password { + return (status: 200, data: self.error(message: "Incorrect login or password", code: .invalidLoginOrPassword)) + } + + return (status: 200, data: readData(from: "login_success")) + } +} diff --git a/AutoCat2Tests/Responses/Login/login_success.json b/AutoCat2Tests/Api/Responses/Login/login_success.json similarity index 100% rename from AutoCat2Tests/Responses/Login/login_success.json rename to AutoCat2Tests/Api/Responses/Login/login_success.json diff --git a/AutoCat2Tests/ApiTests.swift b/AutoCat2Tests/ApiTests.swift index bba9cec..3192706 100644 --- a/AutoCat2Tests/ApiTests.swift +++ b/AutoCat2Tests/ApiTests.swift @@ -4,8 +4,16 @@ import AutoCat2 class ApiTests: XCTestCase { private var api: Api! + + private let testLogin = "test@gmail.com" + private let testPassword = "12345" override func setUpWithError() throws { + MockURLProtocol.baseUrl = Constants.baseUrl + MockURLProtocol.apiMethodMocks = [ + LoginMethodMock(httpMethod: "POST", path: "user/login", login: self.testLogin, password: self.testPassword) + ] + let sessionConfig = URLSessionConfiguration.default sessionConfig.protocolClasses = [MockURLProtocol.self] let session = URLSession(configuration: sessionConfig) @@ -13,31 +21,25 @@ class ApiTests: XCTestCase { } override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + MockURLProtocol.baseUrl = "" + MockURLProtocol.apiMethodMocks = [] } func testLoginSuccess() async throws { - MockURLProtocol.responseType = MockResponseType.loginSuccess - let user = try await self.api.login(email: "", password: "") + let user = try await self.api.login(email: self.testLogin, password: self.testPassword) XCTAssertTrue(!user.token.isEmpty) } func testLoginInvalidParams() async throws { - MockURLProtocol.responseType = MockResponseType.loginWrongCredentials do { _ = try await self.api.login(email: "", password: "") - } catch { + } catch let error as ApiError { + XCTAssertTrue(error.code == .invalidLoginOrPassword) return + } catch { + XCTFail("Wrong exception type") } XCTFail("Exception expected") } - -// func testPerformanceExample() throws { -// // This is an example of a performance test case. -// self.measure { -// // Put the code you want to measure the time of here. -// } -// } - } diff --git a/AutoCat2Tests/Mocks/MockURLProtocol.swift b/AutoCat2Tests/Mocks/MockURLProtocol.swift deleted file mode 100644 index ef804b5..0000000 --- a/AutoCat2Tests/Mocks/MockURLProtocol.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation - -enum MockResponseType: String { - case loginSuccess = "login_success" - case loginWrongCredentials = "login_wrong_credentials" - case -} - -class MockURLProtocol: URLProtocol { - - static var responseType: MockResponseType? - - override class func canInit(with request: URLRequest) -> Bool { - // To check if this protocol can handle the given request. - return true - } - - override class func canonicalRequest(for request: URLRequest) -> URLRequest { - // Here you return the canonical version of the request but most of the time you pass the orignal one. - return request - } - - override func startLoading() { - - guard let respType = MockURLProtocol.responseType else { return } - guard let jsonUrl = Bundle(for: type(of: self)).url(forResource: respType.rawValue, withExtension: "json") else { return } - guard let response = HTTPURLResponse(url: jsonUrl, statusCode: 200, httpVersion: "HTTP/2", headerFields: [:]) else { return } - - let data = try? Data(contentsOf: jsonUrl) - - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - - if let data = data { - client?.urlProtocol(self, didLoad: data) - } - - client?.urlProtocolDidFinishLoading(self) - - //client?.urlProtocol(self, didFailWithError: error) - } - - override func stopLoading() { - // This is called if the request gets canceled or completed. - print("") - } -} diff --git a/AutoCat2Tests/Responses/Login/login_invalid_params.json b/AutoCat2Tests/Responses/Login/login_invalid_params.json deleted file mode 100644 index fe5461a..0000000 --- a/AutoCat2Tests/Responses/Login/login_invalid_params.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "success": false, - "error": "Invalid parameters" -} diff --git a/AutoCat2Tests/Responses/Login/login_wrong_credentials.json b/AutoCat2Tests/Responses/Login/login_wrong_credentials.json deleted file mode 100644 index 01044e5..0000000 --- a/AutoCat2Tests/Responses/Login/login_wrong_credentials.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "success": false, - "error": "Incorrect email or password" -} diff --git a/Shared/Extensions/CocoaError.swift b/Shared/Extensions/CocoaError.swift index 91a5c9e..94f74b0 100644 --- a/Shared/Extensions/CocoaError.swift +++ b/Shared/Extensions/CocoaError.swift @@ -2,16 +2,22 @@ import CoreLocation extension NSError { public var displayMessage: (title: String, body: String) { - if let description = self.userInfo[NSLocalizedDescriptionKey] as? String { - return (title: "Error", body: description) - } else if let failure = self.userInfo[NSLocalizedFailureErrorKey] as? String, let reason = self.localizedFailureReason { - if let recovery = self.localizedRecoverySuggestion { - return (title: failure, body: reason + "\n" + recovery) - } else { - return (title: failure, body: reason) - } +// if let description = self.userInfo[NSLocalizedDescriptionKey] as? String { +// return (title: "Error", body: description) +// } else if let failure = self.userInfo[NSLocalizedFailureErrorKey] as? String, let reason = self.localizedFailureReason { +// if let recovery = self.localizedRecoverySuggestion { +// return (title: failure, body: reason + "\n" + recovery) +// } else { +// return (title: failure, body: reason) +// } +// } else { +// return (title: "Error", body: "") +// } + + if let recovery = self.localizedRecoverySuggestion { + return (title: "Error", body: self.localizedDescription + "\n" + recovery) } else { - return (title: "Error", body: "") + return (title: "Error", body: self.localizedDescription) } } } diff --git a/Shared/Models/Response.swift b/Shared/Models/Response.swift index 979efd6..be792a0 100644 --- a/Shared/Models/Response.swift +++ b/Shared/Models/Response.swift @@ -4,11 +4,13 @@ class Response: Decodable where T: Decodable { let success: Bool let data: T? let error: String? + let errorCode: ApiErrorCode? enum CodingKeys: String, CodingKey { case success case data case error + case errorCode } required init(from decoder: Decoder) throws { @@ -17,8 +19,10 @@ class Response: Decodable where T: Decodable { if success { data = try container.decode(T.self, forKey: .data) error = nil + errorCode = nil } else { error = try container.decode(String.self, forKey: .error) + errorCode = try container.decodeIfPresent(ApiErrorCode.self, forKey: .errorCode) data = nil } } diff --git a/Shared/ThirdParty/AnyEncodable.swift b/Shared/ThirdParty/AnyEncodable.swift new file mode 100644 index 0000000..ac6b954 --- /dev/null +++ b/Shared/ThirdParty/AnyEncodable.swift @@ -0,0 +1,273 @@ +#if canImport(Foundation) +import Foundation +#endif + +/** + A type-erased `Encodable` value. + + The `AnyEncodable` type forwards encoding responsibilities + to an underlying value, hiding its specific underlying type. + + You can encode mixed-type values in dictionaries + and other collections that require `Encodable` conformance + by declaring their contained type to be `AnyEncodable`: + + let dictionary: [String: AnyEncodable] = [ + "boolean": true, + "integer": 1, + "double": 3.141592653589793, + "string": "string", + "array": [1, 2, 3], + "nested": [ + "a": "alpha", + "b": "bravo", + "c": "charlie" + ] + ] + + let encoder = JSONEncoder() + let json = try! encoder.encode(dictionary) + */ +#if swift(>=5.1) +@frozen public struct AnyEncodable: Encodable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } +} +#else +public struct AnyEncodable: Encodable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } +} +#endif + +#if swift(>=4.2) +@usableFromInline +protocol _AnyEncodable { + var value: Any { get } + init(_ value: T?) +} +#else +protocol _AnyEncodable { + var value: Any { get } + init(_ value: T?) +} +#endif + +extension AnyEncodable: _AnyEncodable {} + +// MARK: - Encodable + +extension _AnyEncodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch value { +#if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) + case let number as NSNumber: + try encode(nsnumber: number, into: &container) +#endif +#if canImport(Foundation) + case is NSNull: + try container.encodeNil() +#endif + case is Void: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let int8 as Int8: + try container.encode(int8) + case let int16 as Int16: + try container.encode(int16) + case let int32 as Int32: + try container.encode(int32) + case let int64 as Int64: + try container.encode(int64) + case let uint as UInt: + try container.encode(uint) + case let uint8 as UInt8: + try container.encode(uint8) + case let uint16 as UInt16: + try container.encode(uint16) + case let uint32 as UInt32: + try container.encode(uint32) + case let uint64 as UInt64: + try container.encode(uint64) + case let float as Float: + try container.encode(float) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) +#if canImport(Foundation) + case let date as Date: + try container.encode(date) + case let url as URL: + try container.encode(url) +#endif + case let array as [Any?]: + try container.encode(array.map { AnyEncodable($0) }) + case let dictionary as [String: Any?]: + try container.encode(dictionary.mapValues { AnyEncodable($0) }) + case let enc as Encodable: + //try container.encode(enc) + try enc.encode(to: encoder) + default: + let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyEncodable value cannot be encoded") + throw EncodingError.invalidValue(value, context) + } + } + + #if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) + private func encode(nsnumber: NSNumber, into container: inout SingleValueEncodingContainer) throws { + switch CFNumberGetType(nsnumber) { + case .charType: + try container.encode(nsnumber.boolValue) + case .sInt8Type: + try container.encode(nsnumber.int8Value) + case .sInt16Type: + try container.encode(nsnumber.int16Value) + case .sInt32Type: + try container.encode(nsnumber.int32Value) + case .sInt64Type: + try container.encode(nsnumber.int64Value) + case .shortType: + try container.encode(nsnumber.uint16Value) + case .longType: + try container.encode(nsnumber.uint32Value) + case .longLongType: + try container.encode(nsnumber.uint64Value) + case .intType, .nsIntegerType, .cfIndexType: + try container.encode(nsnumber.intValue) + case .floatType, .float32Type: + try container.encode(nsnumber.floatValue) + case .doubleType, .float64Type, .cgFloatType: + try container.encode(nsnumber.doubleValue) + #if swift(>=5.0) + @unknown default: + let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "NSNumber cannot be encoded because its type is not handled") + throw EncodingError.invalidValue(nsnumber, context) + #endif + } + } + #endif +} + +extension AnyEncodable: Equatable { + public static func == (lhs: AnyEncodable, rhs: AnyEncodable) -> Bool { + switch (lhs.value, rhs.value) { + case is (Void, Void): + return true + case let (lhs as Bool, rhs as Bool): + return lhs == rhs + case let (lhs as Int, rhs as Int): + return lhs == rhs + case let (lhs as Int8, rhs as Int8): + return lhs == rhs + case let (lhs as Int16, rhs as Int16): + return lhs == rhs + case let (lhs as Int32, rhs as Int32): + return lhs == rhs + case let (lhs as Int64, rhs as Int64): + return lhs == rhs + case let (lhs as UInt, rhs as UInt): + return lhs == rhs + case let (lhs as UInt8, rhs as UInt8): + return lhs == rhs + case let (lhs as UInt16, rhs as UInt16): + return lhs == rhs + case let (lhs as UInt32, rhs as UInt32): + return lhs == rhs + case let (lhs as UInt64, rhs as UInt64): + return lhs == rhs + case let (lhs as Float, rhs as Float): + return lhs == rhs + case let (lhs as Double, rhs as Double): + return lhs == rhs + case let (lhs as String, rhs as String): + return lhs == rhs + case let (lhs as [String: AnyEncodable], rhs as [String: AnyEncodable]): + return lhs == rhs + case let (lhs as [AnyEncodable], rhs as [AnyEncodable]): + return lhs == rhs + default: + return false + } + } +} + +extension AnyEncodable: CustomStringConvertible { + public var description: String { + switch value { + case is Void: + return String(describing: nil as Any?) + case let value as CustomStringConvertible: + return value.description + default: + return String(describing: value) + } + } +} + +extension AnyEncodable: CustomDebugStringConvertible { + public var debugDescription: String { + switch value { + case let value as CustomDebugStringConvertible: + return "AnyEncodable(\(value.debugDescription))" + default: + return "AnyEncodable(\(description))" + } + } +} + +extension AnyEncodable: ExpressibleByNilLiteral {} +extension AnyEncodable: ExpressibleByBooleanLiteral {} +extension AnyEncodable: ExpressibleByIntegerLiteral {} +extension AnyEncodable: ExpressibleByFloatLiteral {} +extension AnyEncodable: ExpressibleByStringLiteral {} +#if swift(>=5.0) +extension AnyEncodable: ExpressibleByStringInterpolation {} +#endif +extension AnyEncodable: ExpressibleByArrayLiteral {} +extension AnyEncodable: ExpressibleByDictionaryLiteral {} + +extension _AnyEncodable { + public init(nilLiteral _: ()) { + self.init(nil as Any?) + } + + public init(booleanLiteral value: Bool) { + self.init(value) + } + + public init(integerLiteral value: Int) { + self.init(value) + } + + public init(floatLiteral value: Double) { + self.init(value) + } + + public init(extendedGraphemeClusterLiteral value: String) { + self.init(value) + } + + public init(stringLiteral value: String) { + self.init(value) + } + + public init(arrayLiteral elements: Any...) { + self.init(elements) + } + + public init(dictionaryLiteral elements: (AnyHashable, Any)...) { + self.init([AnyHashable: Any](elements, uniquingKeysWith: { first, _ in first })) + } +} diff --git a/Shared/ThirdParty/GenericJSON/Initialization.swift b/Shared/ThirdParty/GenericJSON/Initialization.swift new file mode 100644 index 0000000..032b0c5 --- /dev/null +++ b/Shared/ThirdParty/GenericJSON/Initialization.swift @@ -0,0 +1,124 @@ +import Foundation + +private struct InitializationError: Error {} + +extension JSON { + + /// Create a JSON value from anything. + /// + /// Argument has to be a valid JSON structure: A `Double`, `Int`, `String`, + /// `Bool`, an `Array` of those types or a `Dictionary` of those types. + /// + /// You can also pass `nil` or `NSNull`, both will be treated as `.null`. + public init(_ value: Any) throws { + switch value { + case _ as NSNull: + self = .null + case let opt as Optional where opt == nil: + self = .null + case let num as NSNumber: + if num.isBool { + self = .bool(num.boolValue) + } else { + self = .number(num.doubleValue) + } + case let str as String: + self = .string(str) + case let bool as Bool: + self = .bool(bool) + case let array as [Any]: + self = .array(try array.map(JSON.init)) + case let dict as [String:Any]: + self = .object(try dict.mapValues(JSON.init)) + default: + throw InitializationError() + } + } +} + +extension JSON { + + /// Create a JSON value from an `Encodable`. This will give you access to the “raw” + /// encoded JSON value the `Encodable` is serialized into. + public init(encodable: T) throws { + let encoded = try JSONEncoder().encode(encodable) + self = try JSONDecoder().decode(JSON.self, from: encoded) + } +} + +extension JSON: ExpressibleByBooleanLiteral { + + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension JSON: ExpressibleByNilLiteral { + + public init(nilLiteral: ()) { + self = .null + } +} + +extension JSON: ExpressibleByArrayLiteral { + + public init(arrayLiteral elements: JSON...) { + self = .array(elements) + } +} + +extension JSON: ExpressibleByDictionaryLiteral { + + public init(dictionaryLiteral elements: (String, JSON)...) { + var object: [String:JSON] = [:] + for (k, v) in elements { + object[k] = v + } + self = .object(object) + } +} + +extension JSON: ExpressibleByFloatLiteral { + + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +extension JSON: ExpressibleByIntegerLiteral { + + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +extension JSON: ExpressibleByStringLiteral { + + public init(stringLiteral value: String) { + self = .string(value) + } +} + +// MARK: - NSNumber + +extension NSNumber { + + /// Boolean value indicating whether this `NSNumber` wraps a boolean. + /// + /// For example, when using `NSJSONSerialization` Bool values are converted into `NSNumber` instances. + /// + /// - seealso: https://stackoverflow.com/a/49641315/3589408 + fileprivate var isBool: Bool { + let objCType = String(cString: self.objCType) + if (self.compare(trueNumber) == .orderedSame && objCType == trueObjCType) || (self.compare(falseNumber) == .orderedSame && objCType == falseObjCType) { + return true + } else { + return false + } + } +} + +private let trueNumber = NSNumber(value: true) +private let falseNumber = NSNumber(value: false) +private let trueObjCType = String(cString: trueNumber.objCType) +private let falseObjCType = String(cString: falseNumber.objCType) diff --git a/Shared/ThirdParty/GenericJSON/JSON.swift b/Shared/ThirdParty/GenericJSON/JSON.swift new file mode 100644 index 0000000..34a05b3 --- /dev/null +++ b/Shared/ThirdParty/GenericJSON/JSON.swift @@ -0,0 +1,82 @@ +import Foundation + +/// A JSON value representation. This is a bit more useful than the naïve `[String:Any]` type +/// for JSON values, since it makes sure only valid JSON values are present & supports `Equatable` +/// and `Codable`, so that you can compare values for equality and code and decode them into data +/// or strings. +@dynamicMemberLookup public enum JSON: Equatable { + case string(String) + case number(Double) + case object([String:JSON]) + case array([JSON]) + case bool(Bool) + case null +} + +extension JSON: Codable { + + public func encode(to encoder: Encoder) throws { + + var container = encoder.singleValueContainer() + + switch self { + case let .array(array): + try container.encode(array) + case let .object(object): + try container.encode(object) + case let .string(string): + try container.encode(string) + case let .number(number): + try container.encode(number) + case let .bool(bool): + try container.encode(bool) + case .null: + try container.encodeNil() + } + } + + public init(from decoder: Decoder) throws { + + let container = try decoder.singleValueContainer() + + if let object = try? container.decode([String: JSON].self) { + self = .object(object) + } else if let array = try? container.decode([JSON].self) { + self = .array(array) + } else if let string = try? container.decode(String.self) { + self = .string(string) + } else if let bool = try? container.decode(Bool.self) { + self = .bool(bool) + } else if let number = try? container.decode(Double.self) { + self = .number(number) + } else if container.decodeNil() { + self = .null + } else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Invalid JSON value.") + ) + } + } +} + +extension JSON: CustomDebugStringConvertible { + + public var debugDescription: String { + switch self { + case .string(let str): + return str.debugDescription + case .number(let num): + return num.debugDescription + case .bool(let bool): + return bool.description + case .null: + return "null" + default: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted] + return try! String(data: encoder.encode(self), encoding: .utf8)! + } + } +} + +extension JSON: Hashable {} diff --git a/Shared/ThirdParty/GenericJSON/Merging.swift b/Shared/ThirdParty/GenericJSON/Merging.swift new file mode 100644 index 0000000..b16e1d2 --- /dev/null +++ b/Shared/ThirdParty/GenericJSON/Merging.swift @@ -0,0 +1,41 @@ +import Foundation + +extension JSON { + + /// Return a new JSON value by merging two other ones + /// + /// If we call the current JSON value `old` and the incoming JSON value + /// `new`, the precise merging rules are: + /// + /// 1. If `old` or `new` are anything but an object, return `new`. + /// 2. If both `old` and `new` are objects, create a merged object like this: + /// 1. Add keys from `old` not present in `new` (“no change” case). + /// 2. Add keys from `new` not present in `old` (“create” case). + /// 3. For keys present in both `old` and `new`, apply merge recursively to their values (“update” case). + public func merging(with new: JSON) -> JSON { + + // If old or new are anything but an object, return new. + guard case .object(let lhs) = self, case .object(let rhs) = new else { + return new + } + + var merged: [String: JSON] = [:] + + // Add keys from old not present in new (“no change” case). + for (key, val) in lhs where rhs[key] == nil { + merged[key] = val + } + + // Add keys from new not present in old (“create” case). + for (key, val) in rhs where lhs[key] == nil { + merged[key] = val + } + + // For keys present in both old and new, apply merge recursively to their values. + for key in lhs.keys where rhs[key] != nil { + merged[key] = lhs[key]?.merging(with: rhs[key]!) + } + + return JSON.object(merged) + } +} diff --git a/Shared/ThirdParty/GenericJSON/Querying.swift b/Shared/ThirdParty/GenericJSON/Querying.swift new file mode 100644 index 0000000..7566b1f --- /dev/null +++ b/Shared/ThirdParty/GenericJSON/Querying.swift @@ -0,0 +1,107 @@ +import Foundation + +public extension JSON { + + /// Return the string value if this is a `.string`, otherwise `nil` + var stringValue: String? { + if case .string(let value) = self { + return value + } + return nil + } + + /// Return the double value if this is a `.number`, otherwise `nil` + var doubleValue: Double? { + if case .number(let value) = self { + return value + } + return nil + } + + /// Return the bool value if this is a `.bool`, otherwise `nil` + var boolValue: Bool? { + if case .bool(let value) = self { + return value + } + return nil + } + + /// Return the object value if this is an `.object`, otherwise `nil` + var objectValue: [String: JSON]? { + if case .object(let value) = self { + return value + } + return nil + } + + /// Return the array value if this is an `.array`, otherwise `nil` + var arrayValue: [JSON]? { + if case .array(let value) = self { + return value + } + return nil + } + + /// Return `true` iff this is `.null` + var isNull: Bool { + if case .null = self { + return true + } + return false + } + + /// If this is an `.array`, return item at index + /// + /// If this is not an `.array` or the index is out of bounds, returns `nil`. + subscript(index: Int) -> JSON? { + if case .array(let arr) = self, arr.indices.contains(index) { + return arr[index] + } + return nil + } + + /// If this is an `.object`, return item at key + subscript(key: String) -> JSON? { + if case .object(let dict) = self { + return dict[key] + } + return nil + } + + /// Dynamic member lookup sugar for string subscripts + /// + /// This lets you write `json.foo` instead of `json["foo"]`. + subscript(dynamicMember member: String) -> JSON? { + return self[member] + } + + /// Return the JSON type at the keypath if this is an `.object`, otherwise `nil` + /// + /// This lets you write `json[keyPath: "foo.bar.jar"]`. + subscript(keyPath keyPath: String) -> JSON? { + return queryKeyPath(keyPath.components(separatedBy: ".")) + } + + func queryKeyPath(_ path: T) -> JSON? where T: Collection, T.Element == String { + + // Only object values may be subscripted + guard case .object(let object) = self else { + return nil + } + + // Is the path non-empty? + guard let head = path.first else { + return nil + } + + // Do we have a value at the required key? + guard let value = object[head] else { + return nil + } + + let tail = path.dropFirst() + + return tail.isEmpty ? value : value.queryKeyPath(tail) + } + +} diff --git a/Shared/Utils/Api.swift b/Shared/Utils/Api.swift index ae78cd0..3b45843 100644 --- a/Shared/Utils/Api.swift +++ b/Shared/Utils/Api.swift @@ -53,6 +53,9 @@ public class Api { } let (data, response) = try await self.session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw self.genError("non-HTTP response received", suggestion: "") + } // let str = String(data: data, encoding: .utf8) // print("================================") @@ -64,11 +67,15 @@ public class Api { // print("================================") do { - let resp = try JSONDecoder().decode(Response.self, from: data) - if resp.success { - return resp.data! + if data.count > 0 { + let resp = try JSONDecoder().decode(Response.self, from: data) + if resp.success { + return resp.data! + } else { + throw ApiError(httpStatus: httpResponse.statusCode, message: resp.error, code: resp.errorCode) + } } else { - throw self.genError(resp.error!, suggestion: "") + throw ApiError(httpStatus: httpResponse.statusCode) } } catch let error as Swift.DecodingError { throw CocoaError.error((error as CustomDebugStringConvertible).debugDescription) diff --git a/Shared/Utils/ApiError.swift b/Shared/Utils/ApiError.swift new file mode 100644 index 0000000..b1be874 --- /dev/null +++ b/Shared/Utils/ApiError.swift @@ -0,0 +1,35 @@ +import Foundation + +public enum ApiErrorCode: Int, CustomStringConvertible, Codable { + case invalidLoginOrPassword = 0 + + public var description: String { + switch self { + case .invalidLoginOrPassword: return "Invalid login or password" + } + } +} + +public class ApiError: LocalizedError { + public let httpStatus: Int + public let message: String? + public let code: ApiErrorCode? + + init(httpStatus: Int, message: String? = nil, code: ApiErrorCode? = nil) { + self.httpStatus = httpStatus + self.message = message + self.code = code + } + + public var errorDescription: String? { + if let code = code { + return code.description + } + + if let message = message { + return message + } + + return "General http error (status \(self.httpStatus))" + } +}