Functions.swift 16 KB

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