RemoteConfigConsole.swift 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. // Copyright 2020 Google LLC
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. class RemoteConfigConsole {
  15. private let projectID: String
  16. private var latestConfig: [String: Any]!
  17. public var requestTimeout: TimeInterval = 10
  18. private var consoleURL: URL {
  19. let api = "https://firebaseremoteconfig.googleapis.com"
  20. let endpoint = "/v1/projects/\(projectID)/remoteConfig"
  21. return URL(string: api + endpoint)!
  22. }
  23. private lazy var accessToken: String = {
  24. guard let fileURL = Bundle(for: type(of: self))
  25. .url(forResource: "AccessToken", withExtension: "json") else {
  26. fatalError("Could not find AccessToken.json in bundle.")
  27. }
  28. guard let data = try? Data(contentsOf: fileURL),
  29. let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments),
  30. let jsonDict = json as? [String: Any],
  31. let accessToken = jsonDict["access_token"] as? String else {
  32. fatalError("Could not retrieve access token.")
  33. }
  34. return accessToken
  35. }()
  36. /// Synchronously fetches and returns currently active Remote Config, if it exists.
  37. public var activeRemoteConfig: [String: Any]? {
  38. var config: [String: Any]?
  39. perform(configRequest: .get) { latestConfigJSON in
  40. config = latestConfigJSON
  41. }
  42. if let config = config {
  43. saveConfig(config)
  44. }
  45. return config
  46. }
  47. /// Exposing this initializer allows us to create`RemoteConfigConsole` instances without
  48. /// depending on a `GoogleService-Info.plist`.
  49. init(projectID: String) {
  50. self.projectID = projectID
  51. syncWithConsole()
  52. }
  53. /// This initializer will attempt to read from a `GoogleService-Info.plist` to set `projectID`.
  54. convenience init() {
  55. let currentBundle = Bundle(for: type(of: self))
  56. let projectID = currentBundle.plistValue(
  57. forKey: "PROJECT_ID",
  58. fromPlist: "GoogleService-Info.plist"
  59. )
  60. self.init(projectID: projectID! as! String)
  61. }
  62. // MARK: - Public API
  63. /// Update Remote Config with multiple String key value pairs.
  64. /// - Parameter parameters: Dictionary representation of config key value pairs.
  65. public func updateRemoteConfig(with parameters: [String: CustomStringConvertible]) {
  66. var updatedConfig: [String: Any] = latestConfig
  67. let latestParameters = latestConfig["parameters"] as? [String: Any]
  68. var updatedParameters = latestParameters ?? [String: Any]()
  69. for (key, value) in parameters {
  70. updatedParameters.updateValue(["defaultValue": ["value": value.description]], forKey: key)
  71. }
  72. updatedConfig.updateValue(updatedParameters, forKey: "parameters")
  73. publish(config: updatedConfig)
  74. }
  75. /// Updates a Remote Config value for a given key.
  76. /// - Parameters:
  77. /// - value: Use strings, numbers, and booleans to represent Remote Config values.
  78. /// - key: The corresponding string key that maps to the given value.
  79. public func updateRemoteConfigValue(_ value: CustomStringConvertible, forKey key: String) {
  80. var updatedConfig: [String: Any] = latestConfig
  81. let latestParameters = latestConfig["parameters"] as? [String: Any]
  82. if var parameters = latestParameters {
  83. parameters.updateValue(["defaultValue": ["value": value.description]], forKey: key)
  84. updatedConfig.updateValue(parameters, forKey: "parameters")
  85. } else {
  86. updatedConfig.updateValue(
  87. [key: ["defaultValue": ["value": value.description]]],
  88. forKey: "parameters"
  89. )
  90. }
  91. publish(config: updatedConfig)
  92. }
  93. public func removeRemoteConfigValue(forKey key: String) {
  94. var updatedConfig: [String: Any] = latestConfig
  95. let latestParameters = latestConfig["parameters"] as? [String: Any]
  96. if var parameters = latestParameters {
  97. parameters.removeValue(forKey: key)
  98. updatedConfig.updateValue(parameters, forKey: "parameters")
  99. }
  100. publish(config: updatedConfig)
  101. }
  102. public func clearRemoteConfig() {
  103. var updatedConfig: [String: Any]! = latestConfig
  104. updatedConfig.removeValue(forKey: "parameters")
  105. publish(config: updatedConfig)
  106. }
  107. // MARK: - Networking
  108. private enum ConfigRequest {
  109. case get, put(_ data: Data)
  110. var httpMethod: String {
  111. switch self {
  112. case .get: return "GET"
  113. case .put(data: _): return "PUT"
  114. }
  115. }
  116. var httpBody: Data? {
  117. switch self {
  118. case .get: return nil
  119. case let .put(data: data): return data
  120. }
  121. }
  122. var httpHeaderFields: [String: String]? {
  123. switch self {
  124. case .get: return nil
  125. case .put(data: _):
  126. return ["Content-Type": "application/json; UTF8", "If-Match": "*"]
  127. }
  128. }
  129. func secureRequest(url: URL, with token: String, _ timeout: TimeInterval = 10) -> URLRequest {
  130. var request = URLRequest(url: url, timeoutInterval: timeout)
  131. request.httpMethod = httpMethod
  132. request.allHTTPHeaderFields = httpHeaderFields
  133. request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
  134. request.httpBody = httpBody
  135. return request
  136. }
  137. }
  138. /// Performs a given `ConfigRequest` synchronously.
  139. private func perform(configRequest: ConfigRequest,
  140. _ completion: (([String: Any]?) -> Void)? = nil) {
  141. let request = configRequest.secureRequest(url: consoleURL, with: accessToken, requestTimeout)
  142. let semaphore = DispatchSemaphore(value: 0)
  143. let task = URLSession.shared.dataTask(with: request) { data, response, error in
  144. // Signal the semaphore when this scope is escaped.
  145. defer { semaphore.signal() }
  146. guard let data = data else {
  147. print(String(describing: error))
  148. return
  149. }
  150. let json = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
  151. if let response = response as? HTTPURLResponse,
  152. let json = json as? [String: Any] {
  153. if response.statusCode >= 400 {
  154. print("RemoteConfigConsole Error: \(String(describing: json["error"]!))")
  155. }
  156. }
  157. completion?(json as? [String: Any])
  158. }
  159. task.resume()
  160. semaphore.wait()
  161. }
  162. /// Publishes a config object to the live console and updates `latestConfig`.
  163. private func publish(config: [String: Any]) {
  164. let configData = data(withConfig: config)
  165. perform(configRequest: .put(configData))
  166. saveConfig(config)
  167. }
  168. // MARK: - Private Helpers
  169. /// Creates an optional Data object given a config object.
  170. /// Used for serializing config objects before posting them to live console.
  171. private func data(withConfig config: [String: Any]) -> Data {
  172. let dictionary = NSDictionary(dictionary: config, copyItems: true)
  173. let data = try! JSONSerialization.data(withJSONObject: dictionary, options: .fragmentsAllowed)
  174. return data
  175. }
  176. /// Perform a synchronous sync with remote config console.
  177. private func syncWithConsole() {
  178. if let consoleConfig = activeRemoteConfig {
  179. latestConfig = consoleConfig
  180. } else {
  181. fatalError("Could not sync with console.")
  182. }
  183. }
  184. /// A more intuitively named setter for `latestConfig`.
  185. private func saveConfig(_ config: [String: Any]) {
  186. latestConfig = config
  187. }
  188. }
  189. // MARK: - Extensions
  190. extension Bundle {
  191. func plistValue(forKey key: String, fromPlist plist: String) -> Any? {
  192. guard let plistURL = url(forResource: plist, withExtension: "") else {
  193. print("Could not find plist file \(plist) in bundle.")
  194. return nil
  195. }
  196. let plistDictionary = NSDictionary(contentsOf: plistURL)
  197. return plistDictionary?.object(forKey: key)
  198. }
  199. }