Functions.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. // Copyright 2022 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. import Foundation
  15. import FirebaseAppCheckInterop
  16. import FirebaseAuthInterop
  17. import FirebaseCore
  18. import FirebaseCoreExtension
  19. import FirebaseMessagingInterop
  20. import FirebaseSharedSwift
  21. #if COCOAPODS
  22. import GTMSessionFetcher
  23. #else
  24. import GTMSessionFetcherCore
  25. #endif
  26. /// File specific constants.
  27. private enum Constants {
  28. static let appCheckTokenHeader = "X-Firebase-AppCheck"
  29. static let fcmTokenHeader = "Firebase-Instance-ID-Token"
  30. }
  31. /// Cross SDK constants.
  32. internal enum FunctionsConstants {
  33. static let defaultRegion = "us-central1"
  34. }
  35. /**
  36. * `Functions` is the client for Cloud Functions for a Firebase project.
  37. */
  38. @objc(FIRFunctions) open class Functions: NSObject {
  39. // MARK: - Private Variables
  40. /// The network client to use for http requests.
  41. private let fetcherService: GTMSessionFetcherService
  42. /// The projectID to use for all function references.
  43. private let projectID: String
  44. /// A serializer to encode/decode data and return values.
  45. private let serializer = FUNSerializer()
  46. /// A factory for getting the metadata to include with function calls.
  47. private let contextProvider: FunctionsContextProvider
  48. /// The custom domain to use for all functions references (optional).
  49. internal let customDomain: String?
  50. /// The region to use for all function references.
  51. internal let region: String
  52. // MARK: - Public APIs
  53. /**
  54. * The current emulator origin, or `nil` if it is not set.
  55. */
  56. open private(set) var emulatorOrigin: String?
  57. /**
  58. * Creates a Cloud Functions client using the default or returns a pre-existing instance if it already exists.
  59. * - Returns: A shared Functions instance initialized with the default `FirebaseApp`.
  60. */
  61. @objc(functions) open class func functions() -> Functions {
  62. return functions(
  63. app: FirebaseApp.app(),
  64. region: FunctionsConstants.defaultRegion,
  65. customDomain: nil
  66. )
  67. }
  68. /**
  69. * Creates a Cloud Functions client with the given app, or returns a pre-existing
  70. * instance if one already exists.
  71. * - Parameter app The app for the Firebase project.
  72. * - Returns: A shared Functions instance initialized with the specified `FirebaseApp`.
  73. */
  74. @objc(functionsForApp:) open class func functions(app: FirebaseApp) -> Functions {
  75. return functions(app: app, region: FunctionsConstants.defaultRegion, customDomain: nil)
  76. }
  77. /**
  78. * Creates a Cloud Functions client with the default app and given region.
  79. * - Parameter region The region for the HTTP trigger, such as `us-central1`.
  80. * - Returns: A shared Functions instance initialized with the default `FirebaseApp` and a custom region.
  81. */
  82. @objc(functionsForRegion:) open class func functions(region: String) -> Functions {
  83. return functions(app: FirebaseApp.app(), region: region, customDomain: nil)
  84. }
  85. /**
  86. * Creates a Cloud Functions client with the given app and region, or returns a pre-existing
  87. * instance if one already exists.
  88. * - Parameter customDomain A custom domain for the HTTP trigger, such as "https://mydomain.com".
  89. * - Returns: A shared Functions instance initialized with the default `FirebaseApp` and a custom HTTP trigger domain.
  90. */
  91. @objc(functionsForCustomDomain:) open class func functions(customDomain: String) -> Functions {
  92. return functions(app: FirebaseApp.app(),
  93. region: FunctionsConstants.defaultRegion, customDomain: customDomain)
  94. }
  95. /**
  96. * Creates a Cloud Functions client with the given app and region, or returns a pre-existing
  97. * instance if one already exists.
  98. * - Parameters:
  99. * - app: The app for the Firebase project.
  100. * - region: The region for the HTTP trigger, such as `us-central1`.
  101. * - Returns: An instance of `Functions` with a custom app and region.
  102. */
  103. @objc(functionsForApp:region:) open class func functions(app: FirebaseApp,
  104. region: String) -> Functions {
  105. return functions(app: app, region: region, customDomain: nil)
  106. }
  107. /**
  108. * Creates a Cloud Functions client with the given app and region, or returns a pre-existing
  109. * instance if one already exists.
  110. * - Parameters:
  111. * - app The app for the Firebase project.
  112. * - customDomain A custom domain for the HTTP trigger, such as `https://mydomain.com`.
  113. * - Returns: An instance of `Functions` with a custom app and HTTP trigger domain.
  114. */
  115. @objc(functionsForApp:customDomain:) open class func functions(app: FirebaseApp,
  116. customDomain: String)
  117. -> Functions {
  118. return functions(app: app, region: FunctionsConstants.defaultRegion, customDomain: customDomain)
  119. }
  120. /**
  121. * Creates a reference to the Callable HTTPS trigger with the given name.
  122. * - Parameter name The name of the Callable HTTPS trigger.
  123. */
  124. @objc(HTTPSCallableWithName:) open func httpsCallable(_ name: String) -> HTTPSCallable {
  125. return HTTPSCallable(functions: self, name: name)
  126. }
  127. @objc(HTTPSCallableWithURL:) open func httpsCallable(_ url: URL) -> HTTPSCallable {
  128. return HTTPSCallable(functions: self, url: url)
  129. }
  130. /// Creates a reference to the Callable HTTPS trigger with the given name, the type of an `Encodable`
  131. /// request and the type of a `Decodable` response.
  132. /// - Parameter name: The name of the Callable HTTPS trigger
  133. /// - Parameter requestAs: The type of the `Encodable` entity to use for requests to this `Callable`
  134. /// - Parameter responseAs: The type of the `Decodable` entity to use for responses from this `Callable`
  135. /// - Parameter encoder: The encoder instance to use to run the encoding.
  136. /// - Parameter decoder: The decoder instance to use to run the decoding.
  137. /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud Functions invocations.
  138. open func httpsCallable<Request: Encodable,
  139. Response: Decodable>(_ name: String,
  140. requestAs: Request.Type = Request.self,
  141. responseAs: Response.Type = Response.self,
  142. encoder: FirebaseDataEncoder = FirebaseDataEncoder(
  143. ),
  144. decoder: FirebaseDataDecoder = FirebaseDataDecoder(
  145. ))
  146. -> Callable<Request, Response> {
  147. return Callable(callable: httpsCallable(name), encoder: encoder, decoder: decoder)
  148. }
  149. /// Creates a reference to the Callable HTTPS trigger with the given name, the type of an `Encodable`
  150. /// request and the type of a `Decodable` response.
  151. /// - Parameter url: The url of the Callable HTTPS trigger
  152. /// - Parameter requestAs: The type of the `Encodable` entity to use for requests to this `Callable`
  153. /// - Parameter responseAs: The type of the `Decodable` entity to use for responses from this `Callable`
  154. /// - Parameter encoder: The encoder instance to use to run the encoding.
  155. /// - Parameter decoder: The decoder instance to use to run the decoding.
  156. /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud Functions invocations.
  157. open func httpsCallable<Request: Encodable,
  158. Response: Decodable>(_ url: URL,
  159. requestAs: Request.Type = Request.self,
  160. responseAs: Response.Type = Response.self,
  161. encoder: FirebaseDataEncoder = FirebaseDataEncoder(
  162. ),
  163. decoder: FirebaseDataDecoder = FirebaseDataDecoder(
  164. ))
  165. -> Callable<Request, Response> {
  166. return Callable(callable: httpsCallable(url), encoder: encoder, decoder: decoder)
  167. }
  168. /**
  169. * Changes this instance to point to a Cloud Functions emulator running locally.
  170. * See https://firebase.google.com/docs/functions/local-emulator
  171. * - Parameters:
  172. * - host: The host of the local emulator, such as "localhost".
  173. * - port: The port of the local emulator, for example 5005.
  174. */
  175. @objc open func useEmulator(withHost host: String, port: Int) {
  176. let prefix = host.hasPrefix("http") ? "" : "http://"
  177. let origin = String(format: "\(prefix)\(host):%li", port)
  178. emulatorOrigin = origin
  179. }
  180. // MARK: - Private Funcs (or Internal for tests)
  181. /// Solely used to have one precondition and one location where we fetch from the container. This
  182. /// previously was avoided due to default arguments but that doesn't work well with Obj-C compatibility.
  183. private class func functions(app: FirebaseApp?, region: String,
  184. customDomain: String?) -> Functions {
  185. precondition(app != nil,
  186. "`FirebaseApp.configure()` needs to be called before using Functions.")
  187. let provider = app!.container.instance(for: FunctionsProvider.self) as? FunctionsProvider
  188. return provider!.functions(for: app!,
  189. region: region,
  190. customDomain: customDomain,
  191. type: self)
  192. }
  193. @objc internal init(projectID: String,
  194. region: String,
  195. customDomain: String?,
  196. auth: AuthInterop?,
  197. messaging: MessagingInterop?,
  198. appCheck: AppCheckInterop?,
  199. fetcherService: GTMSessionFetcherService = GTMSessionFetcherService()) {
  200. self.projectID = projectID
  201. self.region = region
  202. self.customDomain = customDomain
  203. emulatorOrigin = nil
  204. contextProvider = FunctionsContextProvider(auth: auth,
  205. messaging: messaging,
  206. appCheck: appCheck)
  207. self.fetcherService = fetcherService
  208. }
  209. /// Using the component system for initialization.
  210. internal convenience init(app: FirebaseApp,
  211. region: String,
  212. customDomain: String?) {
  213. // TODO: These are not optionals, but they should be.
  214. let auth = ComponentType<AuthInterop>.instance(for: AuthInterop.self, in: app.container)
  215. let messaging = ComponentType<MessagingInterop>.instance(for: MessagingInterop.self,
  216. in: app.container)
  217. let appCheck = ComponentType<AppCheckInterop>.instance(for: AppCheckInterop.self,
  218. in: app.container)
  219. guard let projectID = app.options.projectID else {
  220. fatalError("Firebase Functions requires the projectID to be set in the App's Options.")
  221. }
  222. self.init(projectID: projectID,
  223. region: region,
  224. customDomain: customDomain,
  225. auth: auth,
  226. messaging: messaging,
  227. appCheck: appCheck)
  228. }
  229. internal func urlWithName(_ name: String) -> String {
  230. assert(!name.isEmpty, "Name cannot be empty")
  231. // Check if we're using the emulator
  232. if let emulatorOrigin = emulatorOrigin {
  233. return "\(emulatorOrigin)/\(projectID)/\(region)/\(name)"
  234. }
  235. // Check the custom domain.
  236. if let customDomain = customDomain {
  237. return "\(customDomain)/\(name)"
  238. }
  239. return "https://\(region)-\(projectID).cloudfunctions.net/\(name)"
  240. }
  241. internal func callFunction(name: String,
  242. withObject data: Any?,
  243. timeout: TimeInterval,
  244. completion: @escaping ((Result<HTTPSCallableResult, Error>) -> Void)) {
  245. // Get context first.
  246. contextProvider.getContext { context, error in
  247. // Note: context is always non-nil since some checks could succeed, we're only failing if
  248. // there's an error.
  249. if let error = error {
  250. completion(.failure(error))
  251. } else {
  252. let url = self.urlWithName(name)
  253. self.callFunction(url: URL(string: url)!,
  254. withObject: data,
  255. timeout: timeout,
  256. context: context,
  257. completion: completion)
  258. }
  259. }
  260. }
  261. internal func callFunction(url: URL,
  262. withObject data: Any?,
  263. timeout: TimeInterval,
  264. completion: @escaping ((Result<HTTPSCallableResult, Error>) -> Void)) {
  265. // Get context first.
  266. contextProvider.getContext { context, error in
  267. // Note: context is always non-nil since some checks could succeed, we're only failing if
  268. // there's an error.
  269. if let error = error {
  270. completion(.failure(error))
  271. } else {
  272. self.callFunction(url: url,
  273. withObject: data,
  274. timeout: timeout,
  275. context: context,
  276. completion: completion)
  277. }
  278. }
  279. }
  280. private func callFunction(url: URL,
  281. withObject data: Any?,
  282. timeout: TimeInterval,
  283. context: FunctionsContext,
  284. completion: @escaping ((Result<HTTPSCallableResult, Error>) -> Void)) {
  285. let request = URLRequest(url: url,
  286. cachePolicy: .useProtocolCachePolicy,
  287. timeoutInterval: timeout)
  288. let fetcher = fetcherService.fetcher(with: request)
  289. let body = NSMutableDictionary()
  290. // Encode the data in the body.
  291. var localData = data
  292. if data == nil {
  293. localData = NSNull()
  294. }
  295. // Force unwrap to match the old invalid argument thrown.
  296. let encoded = try! serializer.encode(localData!)
  297. body["data"] = encoded
  298. do {
  299. let payload = try JSONSerialization.data(withJSONObject: body)
  300. fetcher.bodyData = payload
  301. } catch {
  302. DispatchQueue.main.async {
  303. completion(.failure(error))
  304. }
  305. return
  306. }
  307. // Set the headers.
  308. fetcher.setRequestValue("application/json", forHTTPHeaderField: "Content-Type")
  309. if let authToken = context.authToken {
  310. let value = "Bearer \(authToken)"
  311. fetcher.setRequestValue(value, forHTTPHeaderField: "Authorization")
  312. }
  313. if let fcmToken = context.fcmToken {
  314. fetcher.setRequestValue(fcmToken, forHTTPHeaderField: Constants.fcmTokenHeader)
  315. }
  316. if let appCheckToken = context.appCheckToken {
  317. fetcher.setRequestValue(appCheckToken, forHTTPHeaderField: Constants.appCheckTokenHeader)
  318. }
  319. // Override normal security rules if this is a local test.
  320. if emulatorOrigin != nil {
  321. fetcher.allowLocalhostRequest = true
  322. fetcher.allowedInsecureSchemes = ["http"]
  323. }
  324. fetcher.beginFetch { data, error in
  325. // If there was an HTTP error, convert it to our own error domain.
  326. var localError: Error?
  327. if let error = error as NSError? {
  328. if error.domain == kGTMSessionFetcherStatusDomain {
  329. localError = FunctionsErrorForResponse(
  330. status: error.code,
  331. body: data,
  332. serializer: self.serializer
  333. )
  334. } else if error.domain == NSURLErrorDomain, error.code == NSURLErrorTimedOut {
  335. localError = FunctionsErrorCode.deadlineExceeded.generatedError(userInfo: nil)
  336. }
  337. // If there was an error, report it to the user and stop.
  338. if let localError = localError {
  339. completion(.failure(localError))
  340. } else {
  341. completion(.failure(error))
  342. }
  343. return
  344. } else {
  345. // If there wasn't an HTTP error, see if there was an error in the body.
  346. if let bodyError = FunctionsErrorForResponse(
  347. status: 200,
  348. body: data,
  349. serializer: self.serializer
  350. ) {
  351. completion(.failure(bodyError))
  352. return
  353. }
  354. }
  355. // Porting: this check is new since we didn't previously check if `data` was nil.
  356. guard let data = data else {
  357. completion(.failure(FunctionsErrorCode.internal.generatedError(userInfo: nil)))
  358. return
  359. }
  360. let responseJSONObject: Any
  361. do {
  362. responseJSONObject = try JSONSerialization.jsonObject(with: data)
  363. } catch {
  364. completion(.failure(error))
  365. return
  366. }
  367. guard let responseJSON = responseJSONObject as? NSDictionary else {
  368. let userInfo = [NSLocalizedDescriptionKey: "Response was not a dictionary."]
  369. completion(.failure(FunctionsErrorCode.internal.generatedError(userInfo: userInfo)))
  370. return
  371. }
  372. // TODO(klimt): Allow "result" instead of "data" for now, for backwards compatibility.
  373. let dataJSON = responseJSON["data"] ?? responseJSON["result"]
  374. guard let dataJSON = dataJSON as AnyObject? else {
  375. let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."]
  376. completion(.failure(FunctionsErrorCode.internal.generatedError(userInfo: userInfo)))
  377. return
  378. }
  379. let resultData: Any?
  380. do {
  381. resultData = try self.serializer.decode(dataJSON)
  382. } catch {
  383. completion(.failure(error))
  384. return
  385. }
  386. // TODO: Force unwrap... gross
  387. let result = HTTPSCallableResult(data: resultData!)
  388. // TODO: This copied comment appears to be incorrect - it's impossible to have a nil callable result
  389. // If there's no result field, this will return nil, which is fine.
  390. completion(.success(result))
  391. }
  392. }
  393. }