From be67b229e0913c882dc33719581f3b374119737c Mon Sep 17 00:00:00 2001 From: Laurent Date: Thu, 13 Feb 2025 17:00:05 +0100 Subject: [PATCH 1/4] Adds flexible json date decoder + adds app version in http header --- LeStorage.xcodeproj/project.pbxproj | 4 + LeStorage/Services.swift | 121 +++++++++++++---------- LeStorage/Utils/Codable+Extensions.swift | 59 +++++++---- LeStorage/Utils/Date+Extensions.swift | 33 +++++++ 4 files changed, 144 insertions(+), 73 deletions(-) create mode 100644 LeStorage/Utils/Date+Extensions.swift diff --git a/LeStorage.xcodeproj/project.pbxproj b/LeStorage.xcodeproj/project.pbxproj index 82a5a0f..8048c66 100644 --- a/LeStorage.xcodeproj/project.pbxproj +++ b/LeStorage.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456EFE12BE52379007388E2 /* StoredSingleton.swift */; }; C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; }; C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; }; + C49C731C2D5D042D008DD299 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49C731B2D5D042D008DD299 /* Date+Extensions.swift */; }; C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */; }; C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */; }; C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */; }; @@ -52,6 +53,7 @@ C456EFE12BE52379007388E2 /* StoredSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSingleton.swift; sourceTree = ""; }; C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = ""; }; C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = ""; }; + C49C731B2D5D042D008DD299 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = ""; }; C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = ""; }; C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = ""; }; @@ -141,6 +143,7 @@ children = ( C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */, C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */, + C49C731B2D5D042D008DD299 /* Date+Extensions.swift */, C4A47DAE2B85FD3800ADC637 /* Errors.swift */, C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */, C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */, @@ -294,6 +297,7 @@ C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */, C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */, C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */, + C49C731C2D5D042D008DD299 /* Date+Extensions.swift in Sources */, C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */, C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */, C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */, diff --git a/LeStorage/Services.swift b/LeStorage/Services.swift index 63145c7..e258c1c 100644 --- a/LeStorage/Services.swift +++ b/LeStorage/Services.swift @@ -78,20 +78,20 @@ public class Services { /// The base API URL to send requests fileprivate(set) var baseURL: String - fileprivate var jsonEncoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - encoder.outputFormatting = .prettyPrinted - encoder.dateEncodingStrategy = .iso8601 - return encoder - }() - - fileprivate var jsonDecoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 - return decoder - }() +// fileprivate var jsonEncoder: JSONEncoder = { +// let encoder = JSONEncoder() +// encoder.keyEncodingStrategy = .convertToSnakeCase +// encoder.outputFormatting = .prettyPrinted +// encoder.dateEncodingStrategy = .iso8601 +// return encoder +// }() +// +// fileprivate var jsonDecoder: JSONDecoder = { +// let decoder = JSONDecoder() +// decoder.keyDecodingStrategy = .convertFromSnakeCase +// decoder.dateDecodingStrategy = .iso8601 +// return decoder +// }() // MARK: - Base @@ -102,7 +102,7 @@ public class Services { /// - apiCallId: an optional id referencing an ApiCall fileprivate func _runRequest(serviceCall: ServiceCall, payload: T) async throws -> U { var request = try self._baseRequest(call: serviceCall) - request.httpBody = try jsonEncoder.encode(payload) + request.httpBody = try JSON.encoder.encode(payload) return try await _runRequest(request) } @@ -143,9 +143,9 @@ public class Services { } if !(V.self is Empty?.Type) && !(V.self is Empty.Type) { - return try jsonDecoder.decode(V.self, from: task.0) + return try JSON.decoder.decode(V.self, from: task.0) } else { - return try jsonDecoder.decode(V.self, from: "{}".data(using: .utf8)!) + return try JSON.decoder.decode(V.self, from: "{}".data(using: .utf8)!) } } @@ -179,7 +179,7 @@ public class Services { StoreCenter.main.log(message: message) Logger.w(message) } - return try jsonDecoder.decode(V.self, from: task.0) + return try JSON.decoder.decode(V.self, from: task.0) } /// Returns if the token is required for a request @@ -202,38 +202,38 @@ public class Services { let requiresToken = self._isTokenRequired(type: T.self, method: .get) return try self._baseRequest(servicePath: T.path(), method: .get, requiresToken: requiresToken, identifier: identifier) } - - /// Returns a POST request for the resource - /// - Parameters: - /// - type: the type of the request resource - fileprivate func _postRequest(type: T.Type) throws -> URLRequest { - let requiresToken = self._isTokenRequired(type: T.self, method: .post) - return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: requiresToken) - } - - /// Returns a PUT request for the resource - /// - Parameters: - /// - type: the type of the request resource - fileprivate func _putRequest(type: T.Type, id: String) throws -> URLRequest { - let requiresToken = self._isTokenRequired(type: T.self, method: .put) - return try self._baseRequest(servicePath: T.path(id: id), method: .put, requiresToken: requiresToken) - } - - /// Returns a DELETE request for the resource - /// - Parameters: - /// - type: the type of the request resource - fileprivate func _deleteRequest(type: T.Type, id: String) throws -> URLRequest { - let requiresToken = self._isTokenRequired(type: T.self, method: .delete) - return try self._baseRequest(servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken) - } - - /// Returns the base URLRequest for a ServiceConf instance - /// - Parameters: - /// - conf: a ServiceConf instance - fileprivate func _baseRequest(call: ServiceCall) throws -> URLRequest { - return try self._baseRequest(servicePath: call.path, method: call.method, requiresToken: call.requiresToken) - } - + + /// Returns a POST request for the resource + /// - Parameters: + /// - type: the type of the request resource + fileprivate func _postRequest(type: T.Type) throws -> URLRequest { + let requiresToken = self._isTokenRequired(type: T.self, method: .post) + return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: requiresToken) + } + + /// Returns a PUT request for the resource + /// - Parameters: + /// - type: the type of the request resource + fileprivate func _putRequest(type: T.Type, id: String) throws -> URLRequest { + let requiresToken = self._isTokenRequired(type: T.self, method: .put) + return try self._baseRequest(servicePath: T.path(id: id), method: .put, requiresToken: requiresToken) + } + + /// Returns a DELETE request for the resource + /// - Parameters: + /// - type: the type of the request resource + fileprivate func _deleteRequest(type: T.Type, id: String) throws -> URLRequest { + let requiresToken = self._isTokenRequired(type: T.self, method: .delete) + return try self._baseRequest(servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken) + } + + /// Returns the base URLRequest for a ServiceConf instance + /// - Parameters: + /// - conf: a ServiceConf instance + fileprivate func _baseRequest(call: ServiceCall) throws -> URLRequest { + return try self._baseRequest(servicePath: call.path, method: call.method, requiresToken: call.requiresToken) + } + /// Returns a base request for a path and method /// - Parameters: /// - servicePath: the path to add to the API base URL @@ -251,6 +251,7 @@ public class Services { var request = URLRequest(url: url) request.httpMethod = method.rawValue request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.addAppVersion() if !(requiresToken == false) { let token = try self.keychainStore.getValue() request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") @@ -269,14 +270,14 @@ public class Services { /// Executes a POST request public func post(_ instance: T) async throws -> T { var postRequest = try self._postRequest(type: T.self) - postRequest.httpBody = try jsonEncoder.encode(instance) + postRequest.httpBody = try JSON.encoder.encode(instance) return try await self._runRequest(postRequest) } /// Executes a PUT request public func put(_ instance: T) async throws -> T { var postRequest = try self._putRequest(type: T.self, id: instance.stringId) - postRequest.httpBody = try jsonEncoder.encode(instance) + postRequest.httpBody = try JSON.encoder.encode(instance) return try await self._runRequest(postRequest) } @@ -301,6 +302,7 @@ public class Services { request.httpMethod = apiCall.method.rawValue request.httpBody = apiCall.body.data(using: .utf8) request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.addAppVersion() if self._isTokenRequired(type: T.self, method: apiCall.method) { do { @@ -349,7 +351,7 @@ public class Services { var postRequest = try self._baseRequest(call: requestTokenCall) let deviceId = StoreCenter.main.deviceId() let credentials = Credentials(username: username, password: password, deviceId: deviceId) - postRequest.httpBody = try jsonEncoder.encode(credentials) + postRequest.httpBody = try JSON.encoder.encode(credentials) let response: AuthResponse = try await self._runRequest(postRequest) self._storeToken(username: username, token: response.token) return response.token @@ -430,7 +432,7 @@ public class Services { /// - email: the email of the user public func forgotPassword(email: String) async throws { var postRequest = try self._baseRequest(servicePath: "dj-rest-auth/password/reset/", method: .post, requiresToken: false) - postRequest.httpBody = try jsonEncoder.encode(Email(email: email)) + postRequest.httpBody = try JSON.encoder.encode(Email(email: email)) let response: Email = try await self._runRequest(postRequest) Logger.log("response = \(response)") } @@ -537,3 +539,14 @@ public protocol UserBase: Codable { public protocol UserPasswordBase: UserBase { var password: String { get } } + +fileprivate extension URLRequest { + + mutating func addAppVersion() { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + let appVersion = "\(version) (\(build))" + self.setValue(appVersion, forHTTPHeaderField: "App-Version") + } + +} diff --git a/LeStorage/Utils/Codable+Extensions.swift b/LeStorage/Utils/Codable+Extensions.swift index 5bd1c8d..5fd14e7 100644 --- a/LeStorage/Utils/Codable+Extensions.swift +++ b/LeStorage/Utils/Codable+Extensions.swift @@ -7,22 +7,43 @@ import Foundation -fileprivate var jsonEncoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - #if DEBUG - encoder.outputFormatting = .prettyPrinted - #endif - encoder.dateEncodingStrategy = .iso8601 - return encoder -}() +class JSON { + + static var encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + #if DEBUG + encoder.outputFormatting = .prettyPrinted + #endif + encoder.dateEncodingStrategy = .custom { date, encoder in + let dateString = Date.iso8601FractionalFormatter.string(from: date) + var container = encoder.singleValueContainer() + try container.encode(dateString) + } // need dates with thousandth precision + return encoder + }() -fileprivate var jsonDecoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 - return decoder -}() + static var decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + if let date = Date.iso8601FractionalFormatter.date(from: dateString) { + return date + } else if let date = Date.iso8601Formatter.date(from: dateString) { + return date + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid date format: \(dateString)" + ) + } + } // need dates with thousandth precision + return decoder + }() + +} extension Encodable { @@ -32,11 +53,11 @@ extension Encodable { } public func jsonData() throws -> Data { - return try jsonEncoder.encode(self) + return try JSON.encoder.encode(self) } public func prettyJSONString() throws -> String { - let data = try jsonEncoder.encode(self) + let data = try JSON.encoder.encode(self) return String(data: data, encoding: .utf8) ?? "" } @@ -57,11 +78,11 @@ extension String { extension Data { public func decode() throws -> T { - return try jsonDecoder.decode(T.self, from: self) + return try JSON.decoder.decode(T.self, from: self) } public func decodeArray() throws -> [T] { - return try jsonDecoder.decode([T].self, from: self) + return try JSON.decoder.decode([T].self, from: self) } } diff --git a/LeStorage/Utils/Date+Extensions.swift b/LeStorage/Utils/Date+Extensions.swift new file mode 100644 index 0000000..c3d81e6 --- /dev/null +++ b/LeStorage/Utils/Date+Extensions.swift @@ -0,0 +1,33 @@ +// +// Date+Extensions.swift +// LeStorage +// +// Created by Laurent Morvillier on 09/10/2024. +// + +import Foundation + +extension Date { + + static var iso8601Formatter: ISO8601DateFormatter { + let iso8601Formatter = ISO8601DateFormatter() + iso8601Formatter.timeZone = TimeZone(abbreviation: "CET") + iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + return iso8601Formatter + } + + public static var iso8601FractionalFormatter: ISO8601DateFormatter { + let iso8601Formatter = ISO8601DateFormatter() + iso8601Formatter.timeZone = TimeZone(abbreviation: "CET") + iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone, .withFractionalSeconds] + return iso8601Formatter + } + + public static var microSecondFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'" // puts 000 for the last decimals + formatter.timeZone = TimeZone(abbreviation: "UTC") + return formatter + }() + +} From 2b26950d67db5ec0bcac429bf735f0f2cd3f29aa Mon Sep 17 00:00:00 2001 From: Laurent Date: Sat, 15 Feb 2025 18:39:31 +0100 Subject: [PATCH 2/4] Fix api call creation --- LeStorage/ApiCallCollection.swift | 35 ++++++++++--- LeStorageTests/ApiCallTests.swift | 85 +++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 LeStorageTests/ApiCallTests.swift diff --git a/LeStorage/ApiCallCollection.swift b/LeStorage/ApiCallCollection.swift index c0f3bd2..fdab25d 100644 --- a/LeStorage/ApiCallCollection.swift +++ b/LeStorage/ApiCallCollection.swift @@ -216,15 +216,38 @@ actor ApiCallCollection: SomeCallCollection { /// Returns an APICall instance for the Storable [instance] and an HTTP [method] /// The method updates existing calls or creates a new one - fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) async throws -> ApiCall? { + func callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall? { // cleanup let existingCalls = self.items.filter { $0.dataId == instance.stringId } - self._deleteCalls(existingCalls) + if existingCalls.count > 1 { + StoreCenter.main.log(message: "There are multiple calls registered for a single item: \(T.resourceName()), id = \(instance.stringId)") + } + let currentHTTPMethod = existingCalls.first?.method + var call: ApiCall? = nil + if let currentHTTPMethod { + switch (currentHTTPMethod, method) { + case (.post, .put): + call = try self._createCall(instance, method: .post) + case (.post, .delete): + self._deleteCalls(existingCalls) + return nil + case (.put, .put): + call = try self._createCall(instance, method: .put) + case (.put, .delete): + call = try self._createCall(instance, method: .delete) + default: + StoreCenter.main.log(message: "case \(currentHTTPMethod) / \(method) should not happen") + } + } else { + call = try self._createCall(instance, method: method) + } + + if let call { + self._deleteCalls(existingCalls) + self._prepareCall(apiCall: call) + } - // create - let call = try self._createCall(instance, method: method) - self._prepareCall(apiCall: call) return call } @@ -291,7 +314,7 @@ actor ApiCallCollection: SomeCallCollection { /// Initiates the process of sending the data with the server fileprivate func _synchronize(_ instance: T, method: HTTPMethod) async throws -> V? { - if let apiCall = try await self._callForInstance(instance, method: method) { + if let apiCall = try self.callForInstance(instance, method: method) { return try await self._executeApiCall(apiCall) } else { return nil diff --git a/LeStorageTests/ApiCallTests.swift b/LeStorageTests/ApiCallTests.swift new file mode 100644 index 0000000..9ee3401 --- /dev/null +++ b/LeStorageTests/ApiCallTests.swift @@ -0,0 +1,85 @@ +// +// ApiCallTests.swift +// LeStorageTests +// +// Created by Laurent Morvillier on 15/02/2025. +// + +import Testing +@testable import LeStorage + +class Thing: ModelObject, Storable { + static func resourceName() -> String { return "thing" } + static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } + static func filterByStoreIdentifier() -> Bool { return false } + + var id: String = Store.randomId() + var name: String + + init(name: String) { + self.name = name + } +} + +struct ApiCallTests { + + @Test func testApiCallProvisioning1() async throws { + let collection = ApiCallCollection() + + let thing = Thing(name: "yeah") + + let _ = try await collection.sendInsertion(thing) + + await #expect(collection.items.count == 1) + if let apiCall = await collection.items.first { + #expect(apiCall.method == .post) + } + + thing.name = "woo" + let _ = try await collection.sendUpdate(thing) + await #expect(collection.items.count == 1) + if let apiCall = await collection.items.first { + #expect(apiCall.method == .post) + } + + let _ = try await collection.sendDeletion(thing) + await #expect(collection.items.count == 0) + } + + @Test func testApiCallProvisioning2() async throws { + let collection = ApiCallCollection() + + let thing = Thing(name: "yeah") + + let _ = try await collection.sendUpdate(thing) + + await #expect(collection.items.count == 1) + if let apiCall = await collection.items.first { + #expect(apiCall.method == .put) + } + + thing.name = "woo" + let _ = try await collection.sendUpdate(thing) + await #expect(collection.items.count == 1) + if let apiCall = await collection.items.first { + #expect(apiCall.method == .put) + } + + let _ = try await collection.sendDeletion(thing) + await #expect(collection.items.count == 1) + } + + @Test func testApiCallProvisioning3() async throws { + let collection = ApiCallCollection() + + let thing = Thing(name: "yeah") + + let _ = try await collection.sendDeletion(thing) + await #expect(collection.items.count == 1) + let _ = try await collection.sendDeletion(thing) + await #expect(collection.items.count == 1) + let _ = try await collection.sendDeletion(thing) + await #expect(collection.items.count == 1) + } + +} From 389f5b851ea8a7ece43bd0cddaa9d1f7dee66478 Mon Sep 17 00:00:00 2001 From: Laurent Date: Sun, 16 Feb 2025 11:48:48 +0100 Subject: [PATCH 3/4] Fix issue with call creation --- LeStorage/ApiCallCollection.swift | 3 +-- LeStorageTests/ApiCallTests.swift | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/LeStorage/ApiCallCollection.swift b/LeStorage/ApiCallCollection.swift index fdab25d..f87d2c0 100644 --- a/LeStorage/ApiCallCollection.swift +++ b/LeStorage/ApiCallCollection.swift @@ -230,8 +230,7 @@ actor ApiCallCollection: SomeCallCollection { case (.post, .put): call = try self._createCall(instance, method: .post) case (.post, .delete): - self._deleteCalls(existingCalls) - return nil + call = try self._createCall(instance, method: .delete) case (.put, .put): call = try self._createCall(instance, method: .put) case (.put, .delete): diff --git a/LeStorageTests/ApiCallTests.swift b/LeStorageTests/ApiCallTests.swift index 9ee3401..bd5a7eb 100644 --- a/LeStorageTests/ApiCallTests.swift +++ b/LeStorageTests/ApiCallTests.swift @@ -43,7 +43,7 @@ struct ApiCallTests { } let _ = try await collection.sendDeletion(thing) - await #expect(collection.items.count == 0) + await #expect(collection.items.count == 1) } @Test func testApiCallProvisioning2() async throws { From 407640b35c6e599f82f6206d0b058deb61282efa Mon Sep 17 00:00:00 2001 From: Laurent Date: Sun, 16 Feb 2025 11:50:19 +0100 Subject: [PATCH 4/4] add more resilience for calls --- LeStorage/ApiCallCollection.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/LeStorage/ApiCallCollection.swift b/LeStorage/ApiCallCollection.swift index f87d2c0..a917aea 100644 --- a/LeStorage/ApiCallCollection.swift +++ b/LeStorage/ApiCallCollection.swift @@ -236,6 +236,7 @@ actor ApiCallCollection: SomeCallCollection { case (.put, .delete): call = try self._createCall(instance, method: .delete) default: + call = try self._createCall(instance, method: method) StoreCenter.main.log(message: "case \(currentHTTPMethod) / \(method) should not happen") } } else {