From bd9ae87048127a599f7fee55be062602c4f33a42 Mon Sep 17 00:00:00 2001 From: Richard Piazza Date: Mon, 21 Mar 2022 08:02:31 -0500 Subject: [PATCH] Consolidated Error Handling --- .../BulletTrainClient-Example.xcscheme | 111 ----------- Example/Pods/Pods.xcodeproj/project.pbxproj | 33 +++- FlagsmithClient/Classes/APIManager.swift | 178 ------------------ FlagsmithClient/Classes/Flagsmith.swift | 8 +- FlagsmithClient/Classes/FlagsmithError.swift | 61 ++++++ .../Classes/Internal/APIManager.swift | 101 ++++++++++ .../{ => Internal}/FlagsmithAnalytics.swift | 6 +- FlagsmithClient/Classes/Internal/Router.swift | 97 ++++++++++ FlagsmithClient/Tests/APIManagerTests.swift | 63 +++++++ FlagsmithClient/Tests/RouterTests.swift | 81 ++++++++ 10 files changed, 437 insertions(+), 302 deletions(-) delete mode 100644 Example/FlagsmithClient.xcodeproj/xcshareddata/xcschemes/BulletTrainClient-Example.xcscheme delete mode 100644 FlagsmithClient/Classes/APIManager.swift create mode 100644 FlagsmithClient/Classes/FlagsmithError.swift create mode 100644 FlagsmithClient/Classes/Internal/APIManager.swift rename FlagsmithClient/Classes/{ => Internal}/FlagsmithAnalytics.swift (92%) create mode 100644 FlagsmithClient/Classes/Internal/Router.swift create mode 100644 FlagsmithClient/Tests/APIManagerTests.swift create mode 100644 FlagsmithClient/Tests/RouterTests.swift diff --git a/Example/FlagsmithClient.xcodeproj/xcshareddata/xcschemes/BulletTrainClient-Example.xcscheme b/Example/FlagsmithClient.xcodeproj/xcshareddata/xcschemes/BulletTrainClient-Example.xcscheme deleted file mode 100644 index f3b83b5..0000000 --- a/Example/FlagsmithClient.xcodeproj/xcshareddata/xcschemes/BulletTrainClient-Example.xcscheme +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Example/Pods/Pods.xcodeproj/project.pbxproj b/Example/Pods/Pods.xcodeproj/project.pbxproj index 8bf5b0f..40cddcb 100644 --- a/Example/Pods/Pods.xcodeproj/project.pbxproj +++ b/Example/Pods/Pods.xcodeproj/project.pbxproj @@ -15,12 +15,14 @@ 3CB1981582BED249B73F39640A046EC6 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73010CC983E3809BECEE5348DA1BB8C6 /* Foundation.framework */; }; 3E267A21653D98B77234CDD42A3A1D59 /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B3C161B19E351C1591E2039D787AD7C /* Identity.swift */; }; 4A510C0AE712CD782D25F2276904F827 /* Flag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDEFBF90EE0DCCBEE1F3D989919BC4A /* Flag.swift */; }; - 7191FDB6E8F195E826AE8D6C713F25DE /* FlagsmithAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = F69C872C8FED92D20AC2B23547F34B35 /* FlagsmithAnalytics.swift */; }; 7219F5929D0335018EE8AB89735695D2 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73010CC983E3809BECEE5348DA1BB8C6 /* Foundation.framework */; }; 83D44370DA79D1E1C2C20ABD98D13475 /* FlagsmithClient-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = 2EF53626DB8E67CE608BFEEB08DF2A5C /* FlagsmithClient-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 9F21DA4355FE6A2AEDDDFFF891DF93B0 /* APIManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EFB71AA5556DD77A4882DCEB418E2C /* APIManager.swift */; }; B7C44FEAEB3099494B79EA35B9F71DE0 /* FlagsmithClient-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = C7A4CB83310920D7A2F76B1F4F715BA5 /* FlagsmithClient-dummy.m */; }; C5344C505516395035EAB8B9FC111FE2 /* Pods-FlagsmithClient_Example-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 0F66D3EFFD8615AE149DCEEE155C049F /* Pods-FlagsmithClient_Example-dummy.m */; }; + E119200D27E4E5CE00820B87 /* FlagsmithError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E119200827E4E5CE00820B87 /* FlagsmithError.swift */; }; + E119200E27E4E5CE00820B87 /* FlagsmithAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = E119200A27E4E5CE00820B87 /* FlagsmithAnalytics.swift */; }; + E119200F27E4E5CE00820B87 /* APIManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E119200B27E4E5CE00820B87 /* APIManager.swift */; }; + E119201027E4E5CE00820B87 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = E119200C27E4E5CE00820B87 /* Router.swift */; }; E190202E27E24E7600C5EE2C /* TypedValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E190202D27E24E7600C5EE2C /* TypedValue.swift */; }; EE4568DD2E22CD3A570CAB24968F9BE7 /* UnknownTypeValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 772F9BEA66F23FD7DB187ACBEB72BA93 /* UnknownTypeValue.swift */; }; /* End PBXBuildFile section */ @@ -53,7 +55,6 @@ 772F9BEA66F23FD7DB187ACBEB72BA93 /* UnknownTypeValue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = UnknownTypeValue.swift; path = FlagsmithClient/Classes/UnknownTypeValue.swift; sourceTree = ""; }; 7CB5FDC929BB21CB23FFC8143AF6322F /* FlagsmithClient.podspec */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; path = FlagsmithClient.podspec; sourceTree = ""; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; 81A815D8A0C28062CD4A8224C6883D5D /* Pods-FlagsmithClient_Example.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = "Pods-FlagsmithClient_Example.modulemap"; sourceTree = ""; }; - 85EFB71AA5556DD77A4882DCEB418E2C /* APIManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = APIManager.swift; path = FlagsmithClient/Classes/APIManager.swift; sourceTree = ""; }; 9B3C161B19E351C1591E2039D787AD7C /* Identity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Identity.swift; path = FlagsmithClient/Classes/Identity.swift; sourceTree = ""; }; 9D940727FF8FB9C785EB98E56350EF41 /* Podfile */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; name = Podfile; path = ../Podfile; sourceTree = SOURCE_ROOT; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; A7D159AFD71C50F45CAAD458140D8648 /* Pods-FlagsmithClient_Example-acknowledgements.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-FlagsmithClient_Example-acknowledgements.plist"; sourceTree = ""; }; @@ -65,10 +66,13 @@ C1CEBBBF5F9F986FB033B56D22422A4A /* Trait.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Trait.swift; path = FlagsmithClient/Classes/Trait.swift; sourceTree = ""; }; C55271DDAE55BE2BA1AFA657FA24971A /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; C7A4CB83310920D7A2F76B1F4F715BA5 /* FlagsmithClient-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "FlagsmithClient-dummy.m"; sourceTree = ""; }; + E119200827E4E5CE00820B87 /* FlagsmithError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FlagsmithError.swift; path = FlagsmithClient/Classes/FlagsmithError.swift; sourceTree = ""; }; + E119200A27E4E5CE00820B87 /* FlagsmithAnalytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlagsmithAnalytics.swift; sourceTree = ""; }; + E119200B27E4E5CE00820B87 /* APIManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIManager.swift; sourceTree = ""; }; + E119200C27E4E5CE00820B87 /* Router.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; E190202D27E24E7600C5EE2C /* TypedValue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = TypedValue.swift; path = FlagsmithClient/Classes/TypedValue.swift; sourceTree = ""; }; E28010F1C58E656FC37588C8A00FEE38 /* Pods-FlagsmithClient_Example-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-FlagsmithClient_Example-Info.plist"; sourceTree = ""; }; ED741D6F7C10EB042045149B0E9347C9 /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; - F69C872C8FED92D20AC2B23547F34B35 /* FlagsmithAnalytics.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FlagsmithAnalytics.swift; path = FlagsmithClient/Classes/FlagsmithAnalytics.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -118,16 +122,16 @@ 1A94E973B95A4D1E5D88FED450500C7B /* FlagsmithClient */ = { isa = PBXGroup; children = ( - 85EFB71AA5556DD77A4882DCEB418E2C /* APIManager.swift */, A8A7BD53895AF16384B78CB7116DEBD7 /* Feature.swift */, 2DDEFBF90EE0DCCBEE1F3D989919BC4A /* Flag.swift */, 757CB2DED75CA13C202204D5B88B7B9A /* Flagsmith.swift */, + E119200827E4E5CE00820B87 /* FlagsmithError.swift */, 287971AEF04B8BFE50BA6B4E9CB5A76E /* Flagsmith+Concurrency.swift */, - F69C872C8FED92D20AC2B23547F34B35 /* FlagsmithAnalytics.swift */, 9B3C161B19E351C1591E2039D787AD7C /* Identity.swift */, C1CEBBBF5F9F986FB033B56D22422A4A /* Trait.swift */, E190202D27E24E7600C5EE2C /* TypedValue.swift */, 772F9BEA66F23FD7DB187ACBEB72BA93 /* UnknownTypeValue.swift */, + E119200927E4E5CE00820B87 /* Internal */, 6990B5B456D0DD19FB8DF9BFCBB4A363 /* Pod */, 126C2805424EF94E7A836D869A03013D /* Support Files */, ); @@ -205,6 +209,17 @@ name = Frameworks; sourceTree = ""; }; + E119200927E4E5CE00820B87 /* Internal */ = { + isa = PBXGroup; + children = ( + E119200A27E4E5CE00820B87 /* FlagsmithAnalytics.swift */, + E119200B27E4E5CE00820B87 /* APIManager.swift */, + E119200C27E4E5CE00820B87 /* Router.swift */, + ); + name = Internal; + path = FlagsmithClient/Classes/Internal; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -319,14 +334,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9F21DA4355FE6A2AEDDDFFF891DF93B0 /* APIManager.swift in Sources */, 0A2426C7E3721ED5AEADF4CC344F6642 /* Feature.swift in Sources */, 4A510C0AE712CD782D25F2276904F827 /* Flag.swift in Sources */, E190202E27E24E7600C5EE2C /* TypedValue.swift in Sources */, 1845D603B597C651F0EE6080EF459DBB /* Flagsmith.swift in Sources */, 2C55F74AE43F34E4F3952F5B86AADBF5 /* Flagsmith+Concurrency.swift in Sources */, - 7191FDB6E8F195E826AE8D6C713F25DE /* FlagsmithAnalytics.swift in Sources */, + E119200F27E4E5CE00820B87 /* APIManager.swift in Sources */, + E119201027E4E5CE00820B87 /* Router.swift in Sources */, B7C44FEAEB3099494B79EA35B9F71DE0 /* FlagsmithClient-dummy.m in Sources */, + E119200E27E4E5CE00820B87 /* FlagsmithAnalytics.swift in Sources */, + E119200D27E4E5CE00820B87 /* FlagsmithError.swift in Sources */, 3E267A21653D98B77234CDD42A3A1D59 /* Identity.swift in Sources */, 36B29445E454DBDF1CD394B9155A9170 /* Trait.swift in Sources */, EE4568DD2E22CD3A570CAB24968F9BE7 /* UnknownTypeValue.swift in Sources */, diff --git a/FlagsmithClient/Classes/APIManager.swift b/FlagsmithClient/Classes/APIManager.swift deleted file mode 100644 index c7d39b6..0000000 --- a/FlagsmithClient/Classes/APIManager.swift +++ /dev/null @@ -1,178 +0,0 @@ -// -// APIManager.swift -// FlagsmithClient -// -// Created by Tomash Tsiupiak on 6/20/19. -// - -import Foundation - -enum Router { - private enum HTTPMethod: String { - case get = "GET" - case post = "POST" - } - - case getFlags - case getIdentity(identity: String) - case postTrait(trait: Trait, identity: String) - case postAnalytics(events: [String:Int]) - - private var method: HTTPMethod { - switch self { - case .getFlags, .getIdentity: - return .get - case .postTrait, .postAnalytics: - return .post - } - } - - private var path: String { - switch self { - case .getFlags: - return "flags/" - case .getIdentity( _): - return "identities/" - case .postTrait( _, _): - return "traits/" - case .postAnalytics( _): - return "analytics/flags/" - } - } - - private var parameters: [URLQueryItem] { - switch self { - case .getFlags: - return [] - case .getIdentity(let identity): - return [URLQueryItem(name: "identifier", value: identity)] - case .postTrait( _, _): - return [] - case .postAnalytics( _): - return [] - } - } - - private var body: Result { - switch self { - case .getFlags, .getIdentity: - return .success(nil) - case .postTrait(let trait, let identifier): - do { - let postTraitStruct = Trait(trait: trait, identifier: identifier) - let json = try JSONEncoder().encode(postTraitStruct) - return .success(json) - } catch { - return .failure(error) - } - case .postAnalytics(let events): - do { - let json = try JSONEncoder().encode(events) - return .success(json) - } catch { - return .failure(error) - } - } - } - - func request(baseUrl: URL, apiKey: String) throws -> URLRequest { - let urlComponents = NSURLComponents(string:baseUrl.appendingPathComponent(path).absoluteString)! - urlComponents.queryItems = parameters - var request = URLRequest(url: urlComponents.url!) - request.httpMethod = method.rawValue - - switch body { - case .success(let body): - request.httpBody = body - case .failure(let error): - throw error - } - - request.addValue(apiKey, forHTTPHeaderField: "X-Environment-Key") - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - - return request - } -} - -class APIManager { - enum NetworkError: Error { - case noResponseBody - case emptyResponseExpectsString - case defaultError - - var localizedDescription: String { - switch self { - case .noResponseBody: - return NSLocalizedString("Response has no message body", comment: "No response body network error") - case .emptyResponseExpectsString: - return NSLocalizedString("Empty response expects a String type", comment: "Please ensure this method is called with a String Result type") - case .defaultError: - return NSLocalizedString("Some error occurred", comment: "Default network error") - } - } - } - - private let session: URLSession - - var baseURL = URL(string: "https://api.flagsmith.com/api/v1/")! - var apiKey: String? - - init() { - let configuration = URLSessionConfiguration.default - self.session = URLSession(configuration: configuration) - } - - func request(_ router: Router, emptyResponse:Bool = false, completion: @escaping (Result) -> Void) { - guard let apiKey = apiKey else { - fatalError("API Key is missing") - } - - do { - let task = try session.dataTask(with: router.request(baseUrl: baseURL, apiKey: apiKey)) { (data, response, error) -> Void in - if let error = error { - print("URL Session Task Failed: %@", error.localizedDescription) - completion(.failure(error)) - return - } - - guard let statusCode = (response as? HTTPURLResponse)?.statusCode, (200...299).contains(statusCode) else { - let error = NetworkError.defaultError - print("HTTP Request Failed: %@", error.localizedDescription) - completion(.failure(error)) - return - } - - guard let data = data else { - let error = NetworkError.noResponseBody - print("HTTP Request Failed: %@", error.localizedDescription) - completion(.failure(error)) - return - } - - do { - if emptyResponse { - if let result = "" as? T { - completion(.success(result)) - } - else { - completion(.failure(NetworkError.emptyResponseExpectsString)) - } - } - else { - let result = try JSONDecoder().decode(T.self, from: data) - completion(.success(result)) - } - } catch { - print("JSON Decoding Failed: %@", error.localizedDescription) - completion(.failure(error)) - } - } - task.resume() - } catch { - print("Building HTTP Request Failed: %@", error.localizedDescription) - completion(.failure(error)) - } - } - -} diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index 817a0c2..fd294ee 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -7,6 +7,8 @@ import Foundation +/// Manage feature flags and remote config across multiple projects, +/// environments and organisations. public class Flagsmith { /// Shared singleton client object public static let shared = Flagsmith() @@ -14,12 +16,16 @@ public class Flagsmith { private lazy var analytics = FlagsmithAnalytics(apiManager: apiManager) /// Base URL + /// + /// The default implementation uses: `https://api.flagsmith.com/api/v1`. public var baseURL: URL { set { apiManager.baseURL = newValue } get { apiManager.baseURL } } - /// API Key + /// API Key unique to your organization. + /// + /// This value must be provided before any request can succeed. public var apiKey: String? { set { apiManager.apiKey = newValue } get { apiManager.apiKey } diff --git a/FlagsmithClient/Classes/FlagsmithError.swift b/FlagsmithClient/Classes/FlagsmithError.swift new file mode 100644 index 0000000..8f5fc43 --- /dev/null +++ b/FlagsmithClient/Classes/FlagsmithError.swift @@ -0,0 +1,61 @@ +// +// FlagsmithError.swift +// FlagsmithClient +// +// Created by Richard Piazza on 3/18/22. +// + +import Foundation + +/// All errors that can be encountered while using the **FlagsmithClient** +public enum FlagsmithError: LocalizedError { + /// API Key was not provided or invalid. + case apiKey + /// API URL was invalid. + case apiURL(String) + /// API request could not be encoded. + case encoding(EncodingError) + /// API Response status code was not expected. + case statusCode(Int) + /// API Response could not be decoded. + case decoding(DecodingError) + /// Unknown or unhandled error was encountered. + case unhandled(Error) + + public var errorDescription: String? { + switch self { + case .apiKey: + return "API Key was not provided or invalid" + case .apiURL(let path): + return "API URL '\(path)' was invalid" + case .encoding(let error): + return "API Request could not be encoded: \(error.localizedDescription)" + case .statusCode(let code): + return "API Status Code '\(code)' was not expected." + case .decoding(let error): + return "API Response could not be decoded: \(error.localizedDescription)" + case .unhandled(let error): + return "An unknown or unhandled error was encountered: \(error.localizedDescription)" + } + } + + /// Initialize a `FlagsmithError` using an existing `Swift.Error`. + /// + /// The error provided will be processed in several ways: + /// * as `FlagsmithError`: The instance will be directly assigned. + /// * as `EncodingError`: `.encoding()` error will be created. + /// * as `DecodingError`: `.decoding()` error will be created. + /// * default: `.unhandled()` error will be created. + internal init(_ error: Error) { + switch error { + case let flagsmithError as FlagsmithError: + self = flagsmithError + case let encodingError as EncodingError: + self = .encoding(encodingError) + case let decodingError as DecodingError: + self = .decoding(decodingError) + default: + self = .unhandled(error) + } + } +} diff --git a/FlagsmithClient/Classes/Internal/APIManager.swift b/FlagsmithClient/Classes/Internal/APIManager.swift new file mode 100644 index 0000000..a8a21c4 --- /dev/null +++ b/FlagsmithClient/Classes/Internal/APIManager.swift @@ -0,0 +1,101 @@ +// +// APIManager.swift +// FlagsmithClient +// +// Created by Tomash Tsiupiak on 6/20/19. +// + +import Foundation + +/// Handles interaction with a **Flagsmith** api. +class APIManager { + + private let session: URLSession + + /// Base `URL` used for requests. + var baseURL = URL(string: "https://api.flagsmith.com/api/v1/")! + /// API Key unique to an organization. + var apiKey: String? + + init() { + let configuration = URLSessionConfiguration.default + self.session = URLSession(configuration: configuration) + } + + /// Base request method that handles creating a `URLRequest` and processing + /// the `URLSession` response. + /// + /// - parameters: + /// - router: The path and parameters that should be requested. + /// - completion: Function block executed with the result of the request. + private func request(_ router: Router, completion: @escaping (Result) -> Void) { + guard let apiKey = apiKey, !apiKey.isEmpty else { + completion(.failure(FlagsmithError.apiKey)) + return + } + + let request: URLRequest + do { + request = try router.request(baseUrl: baseURL, apiKey: apiKey) + } catch { + completion(.failure(error)) + return + } + + session.dataTask(with: request) { data, response, error in + guard error == nil else { + completion(.failure(FlagsmithError.unhandled(error!))) + return + } + + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200...299).contains(statusCode) else { + completion(.failure(FlagsmithError.statusCode(statusCode))) + return + } + + // The documentation indicates the data should be provided + // since error was found to be nil at this point. Either way + // the 'Decodable' variation will handle any invalid `Data`. + completion(.success(data ?? Data())) + }.resume() + } + + /// Requests a api route and only relays success or failure of the action. + /// + /// - parameters: + /// - router: The path and parameters that should be requested. + /// - completion: Function block executed with the result of the request. + func request(_ router: Router, completion: @escaping (Result) -> Void) { + request(router) { (result: Result) in + switch result { + case .failure(let error): + completion(.failure(FlagsmithError(error))) + case .success: + completion(.success(())) + } + } + } + + /// Requests a api route and attempts the decode the response. + /// + /// - parameters: + /// - router: The path and parameters that should be requested. + /// - decoder: `JSONDecoder` used to deserialize the response data. + /// - completion: Function block executed with the result of the request. + func request(_ router: Router, using decoder: JSONDecoder = JSONDecoder(), completion: @escaping (Result) -> Void) { + request(router) { (result: Result) in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let value = try decoder.decode(T.self, from: data) + completion(.success(value)) + } catch { + completion(.failure(FlagsmithError(error))) + } + } + } + } +} diff --git a/FlagsmithClient/Classes/FlagsmithAnalytics.swift b/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift similarity index 92% rename from FlagsmithClient/Classes/FlagsmithAnalytics.swift rename to FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift index b09020e..c15421d 100644 --- a/FlagsmithClient/Classes/FlagsmithAnalytics.swift +++ b/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift @@ -7,6 +7,7 @@ import Foundation +/// Internal analytics for the **FlagsmithClient** class FlagsmithAnalytics { /// Indicates if analytics are enabled. @@ -69,10 +70,7 @@ class FlagsmithAnalytics { return } - apiManager.request( - .postAnalytics(events: events), - emptyResponse: true - ) { [weak self] (result: Result) in + apiManager.request(.postAnalytics(events: events)) { [weak self] (result: Result) in switch result { case .failure: print("Upload analytics failed") diff --git a/FlagsmithClient/Classes/Internal/Router.swift b/FlagsmithClient/Classes/Internal/Router.swift new file mode 100644 index 0000000..27ee699 --- /dev/null +++ b/FlagsmithClient/Classes/Internal/Router.swift @@ -0,0 +1,97 @@ +// +// Router.swift +// FlagsmithClient +// +// Created by Richard Piazza on 3/18/22. +// + +import Foundation + +enum Router { + private enum HTTPMethod: String { + case get = "GET" + case post = "POST" + } + + case getFlags + case getIdentity(identity: String) + case postTrait(trait: Trait, identity: String) + case postAnalytics(events: [String:Int]) + + private var method: HTTPMethod { + switch self { + case .getFlags, .getIdentity: + return .get + case .postTrait, .postAnalytics: + return .post + } + } + + private var path: String { + switch self { + case .getFlags: + return "flags/" + case .getIdentity: + return "identities/" + case .postTrait: + return "traits/" + case .postAnalytics: + return "analytics/flags/" + } + } + + private var parameters: [URLQueryItem]? { + switch self { + case .getIdentity(let identity): + return [URLQueryItem(name: "identifier", value: identity)] + default: + return nil + } + } + + private func body(using encoder: JSONEncoder) throws -> Data? { + switch self { + case .getFlags, .getIdentity: + return nil + case .postTrait(let trait, let identifier): + let traitWithIdentity = Trait(trait: trait, identifier: identifier) + return try encoder.encode(traitWithIdentity) + case .postAnalytics(let events): + return try encoder.encode(events) + } + } + + /// Generate a `URLRequest` with headers and encoded body. + /// + /// - parameters: + /// - baseUrl: The base URL of the api on which to base the request. + /// - apiKey: The organization key to provide in the request headers. + /// - encoder: `JSONEncoder` used to encode the request body. + func request(baseUrl: URL, + apiKey: String, + using encoder: JSONEncoder = JSONEncoder() + ) throws -> URLRequest { + let urlString = baseUrl.appendingPathComponent(path).absoluteString + var urlComponents = URLComponents(string: urlString) + urlComponents?.queryItems = parameters + guard let url = urlComponents?.url else { + // This is unlikely to ever be hit, but it is safer than + // relying on the forcefully-unwrapped optional. + throw FlagsmithError.apiURL(urlString) + } + + guard !url.isFileURL else { + throw FlagsmithError.apiURL(urlString) + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + if let body = try self.body(using: encoder) { + request.httpBody = body + } + request.addValue(apiKey, forHTTPHeaderField: "X-Environment-Key") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + return request + } +} diff --git a/FlagsmithClient/Tests/APIManagerTests.swift b/FlagsmithClient/Tests/APIManagerTests.swift new file mode 100644 index 0000000..26c3198 --- /dev/null +++ b/FlagsmithClient/Tests/APIManagerTests.swift @@ -0,0 +1,63 @@ +// +// APIManagerTests.swift +// FlagsmithClientTests +// +// Created by Richard Piazza on 3/18/22. +// + +import XCTest +@testable import FlagsmithClient + +final class APIManagerTests: FlagsmithClientTestCase { + + let apiManager = APIManager() + + /// Verify that an invalid API key produces the expected error. + func testInvalidAPIKey() throws { + apiManager.apiKey = nil + + let requestFinished = expectation(description: "Request Finished") + var error: FlagsmithError? + + apiManager.request(.getFlags) { (result: Result) in + if case let .failure(e) = result { + error = e as? FlagsmithError + } + + requestFinished.fulfill() + } + + wait(for: [requestFinished], timeout: 1.0) + + let flagsmithError = try XCTUnwrap(error) + guard case .apiKey = flagsmithError else { + XCTFail("Wrong Error") + return + } + } + + /// Verify that an invalid API url produces the expected error. + func testInvalidAPIURL() throws { + apiManager.apiKey = "8D5ABC87-6BBF-4AE7-BC05-4DC1AFE770DF" + apiManager.baseURL = URL(fileURLWithPath: "/dev/null") + + let requestFinished = expectation(description: "Request Finished") + var error: FlagsmithError? + + apiManager.request(.getFlags) { (result: Result) in + if case let .failure(e) = result { + error = e as? FlagsmithError + } + + requestFinished.fulfill() + } + + wait(for: [requestFinished], timeout: 1.0) + + let flagsmithError = try XCTUnwrap(error) + guard case .apiURL = flagsmithError else { + XCTFail("Wrong Error") + return + } + } +} diff --git a/FlagsmithClient/Tests/RouterTests.swift b/FlagsmithClient/Tests/RouterTests.swift new file mode 100644 index 0000000..fd799b3 --- /dev/null +++ b/FlagsmithClient/Tests/RouterTests.swift @@ -0,0 +1,81 @@ +// +// RouterTests.swift +// FlagsmithClientTests +// +// Created by Richard Piazza on 3/21/22. +// + +import XCTest +@testable import FlagsmithClient + +final class RouterTests: FlagsmithClientTestCase { + + let baseUrl = URL(string: "https://api.flagsmith.com/api/v1") + let apiKey = "E71DC632-82BA-4522-82F3-D39FB6DC90AC" + + func testGetFlagsRequest() throws { + let url = try XCTUnwrap(baseUrl) + let route = Router.getFlags + let request = try route.request(baseUrl: url, apiKey: apiKey) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.absoluteString, "https://api.flagsmith.com/api/v1/flags/") + XCTAssertTrue(request.allHTTPHeaderFields?.contains(where: { $0.key == "X-Environment-Key" }) ?? false) + XCTAssertNil(request.httpBody) + } + + func testGetIdentityRequest() throws { + let url = try XCTUnwrap(baseUrl) + let route = Router.getIdentity(identity: "6056BCBF") + let request = try route.request(baseUrl: url, apiKey: apiKey) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.absoluteString, "https://api.flagsmith.com/api/v1/identities/?identifier=6056BCBF") + XCTAssertTrue(request.allHTTPHeaderFields?.contains(where: { $0.key == "X-Environment-Key" }) ?? false) + XCTAssertNil(request.httpBody) + } + + func testPostTraitsRequest() throws { + let trait = Trait(key: "meaning_of_life", value: 42) + let url = try XCTUnwrap(baseUrl) + let route = Router.postTrait(trait: trait, identity: "CFF8D9CA") + let request = try route.request(baseUrl: url, apiKey: apiKey, using: encoder) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.absoluteString, "https://api.flagsmith.com/api/v1/traits/") + + let json = """ + { + "identity" : { + "identifier" : "CFF8D9CA" + }, + "trait_key" : "meaning_of_life", + "trait_value" : 42 + } + """ + let data = try XCTUnwrap(json.data(using: .utf8)) + + XCTAssertEqual(request.httpBody, data) + } + + func testPostAnalyticsRequest() throws { + let events: [String: Int] = [ + "one": 1, + "two": 2 + ] + + let url = try XCTUnwrap(baseUrl) + let route = Router.postAnalytics(events: events) + let request = try route.request(baseUrl: url, apiKey: apiKey, using: encoder) + + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.absoluteString, "https://api.flagsmith.com/api/v1/analytics/flags/") + + let json = """ + { + "one" : 1, + "two" : 2 + } + """ + let data = try XCTUnwrap(json.data(using: .utf8)) + + XCTAssertEqual(request.httpBody, data) + } +}