|
| 1 | +// |
| 2 | +// File.swift |
| 3 | +// |
| 4 | +// |
| 5 | +// Created by Ryan Wilson on 2022-01-25. |
| 6 | +// |
| 7 | + |
| 8 | +import Foundation |
| 9 | +import FirebaseCore |
| 10 | +import FirebaseSharedSwift |
| 11 | +import GTMSessionFetcherCore |
| 12 | + |
| 13 | +// PLACEHOLDERS |
| 14 | +protocol AuthInterop { |
| 15 | + func getToken(forcingRefresh: Bool, callback: (Result<String, Error>) -> Void) |
| 16 | +} |
| 17 | +protocol MessagingInterop { |
| 18 | + var fcmToken: String { get } |
| 19 | +} |
| 20 | +protocol AppCheckInterop { |
| 21 | + func getToken(forcingRefresh: Bool, callback: (Result<String, Error>) -> Void) |
| 22 | +} |
| 23 | + |
| 24 | +// END PLACEHOLDERS |
| 25 | + |
| 26 | +private enum Constants { |
| 27 | + static let appCheckTokenHeader = "X-Firebase-AppCheck" |
| 28 | + static let fcmTokenHeader = "Firebase-Instance-ID-Token" |
| 29 | +} |
| 30 | + |
| 31 | +/** |
| 32 | + * `Functions` is the client for Cloud Functions for a Firebase project. |
| 33 | + */ |
| 34 | +@objc public class Functions : NSObject { |
| 35 | + |
| 36 | + // MARK: - Private Variables |
| 37 | + |
| 38 | + /// The network client to use for http requests. |
| 39 | + private let fetcherService: GTMSessionFetcherService |
| 40 | + // The projectID to use for all function references. |
| 41 | + private let projectID: String |
| 42 | + // The region to use for all function references. |
| 43 | + private let region: String |
| 44 | + // The custom domain to use for all functions references (optional). |
| 45 | + private let customDomain: String? |
| 46 | + // A serializer to encode/decode data and return values. |
| 47 | + private let serializer = FUNSerializer() |
| 48 | + // A factory for getting the metadata to include with function calls. |
| 49 | + private let contextProvider: FunctionsContextProvider |
| 50 | + |
| 51 | + /** |
| 52 | + * The current emulator origin, or nil if it is not set. |
| 53 | + */ |
| 54 | + private(set) var emulatorOrigin: String? |
| 55 | + |
| 56 | + /** |
| 57 | + * Creates a Cloud Functions client with the given app and region, or returns a pre-existing |
| 58 | + * instance if one already exists. |
| 59 | + * @param app The app for the Firebase project. |
| 60 | + * @param region The region for the http trigger, such as "us-central1". |
| 61 | + */ |
| 62 | + class func functions(app: FirebaseApp = FirebaseApp.app()!, region: String = "us-central1") -> Functions { |
| 63 | + return Functions(app: app, region: region, customDomain: nil) |
| 64 | + } |
| 65 | + |
| 66 | + /** |
| 67 | + * Creates a Cloud Functions client with the given app and region, or returns a pre-existing |
| 68 | + * instance if one already exists. |
| 69 | + * @param app The app for the Firebase project. |
| 70 | + * @param customDomain A custom domain for the http trigger, such as "https://mydomain.com". |
| 71 | + */ |
| 72 | + class func functions(app: FirebaseApp = FirebaseApp.app()!, customDomain: String) -> Functions { |
| 73 | + return Functions(app: app, region: "us-central1", customDomain: customDomain) |
| 74 | + } |
| 75 | + |
| 76 | + internal convenience init(app: FirebaseApp, |
| 77 | + region: String, |
| 78 | + customDomain: String?) { |
| 79 | + #warning("Should be fetched from the App's component container instead.") |
| 80 | + /* |
| 81 | + id<FIRFunctionsProvider> provider = FIR_COMPONENT(FIRFunctionsProvider, app.container); |
| 82 | + return [provider functionsForApp:app region:region customDomain:customDomain type:[self class]]; |
| 83 | + */ |
| 84 | + self.init(projectID: app.options.projectID!, |
| 85 | + region: region, |
| 86 | + customDomain: customDomain, |
| 87 | + // TODO: Get this out of the app. |
| 88 | + auth: nil, |
| 89 | + messaging: nil, |
| 90 | + appCheck: nil) |
| 91 | + } |
| 92 | + |
| 93 | + private init(projectID: String, |
| 94 | + region: String, |
| 95 | + customDomain: String?, |
| 96 | + auth: AuthInterop?, |
| 97 | + messaging: MessagingInterop?, |
| 98 | + appCheck: AppCheckInterop?, |
| 99 | + fetcherService: GTMSessionFetcherService = GTMSessionFetcherService()) { |
| 100 | + self.projectID = projectID |
| 101 | + self.region = region |
| 102 | + self.customDomain = customDomain |
| 103 | + self.emulatorOrigin = nil |
| 104 | + self.contextProvider = FunctionsContextProvider(auth: auth, |
| 105 | + messaging: messaging, |
| 106 | + appCheck: appCheck) |
| 107 | + self.fetcherService = fetcherService |
| 108 | + } |
| 109 | + |
| 110 | + /** |
| 111 | + * Creates a reference to the Callable HTTPS trigger with the given name. |
| 112 | + * @param name The name of the Callable HTTPS trigger. |
| 113 | + */ |
| 114 | + func httpsCallable(_ name: String) -> HTTPSCallable { |
| 115 | + return HTTPSCallable(functions: self, name: name) |
| 116 | + } |
| 117 | + |
| 118 | + /** |
| 119 | + * Changes this instance to point to a Cloud Functions emulator running locally. |
| 120 | + * See https://firebase.google.com/docs/functions/local-emulator |
| 121 | + * @param host The host of the local emulator, such as "localhost". |
| 122 | + * @param port The port of the local emulator, for example 5005. |
| 123 | + */ |
| 124 | + func useEmulator(withHost host: String, port: Int) { |
| 125 | + let prefix = host.hasPrefix("http") ? "" : "http://" |
| 126 | + let origin = String(format: "\(prefix)\(host):%li", port) |
| 127 | + emulatorOrigin = origin |
| 128 | + } |
| 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 | + func httpsCallable<Request: Encodable, |
| 138 | + Response: Decodable>(_ name: String, |
| 139 | + requestAs: Request.Type = Request.self, |
| 140 | + responseAs: Response.Type = Response.self, |
| 141 | + encoder: FirebaseDataEncoder = FirebaseDataEncoder(), |
| 142 | + decoder: FirebaseDataDecoder = FirebaseDataDecoder()) |
| 143 | + -> Callable<Request, Response> { |
| 144 | + return Callable(callable: httpsCallable(name), encoder: encoder, decoder: decoder) |
| 145 | + } |
| 146 | + |
| 147 | + // MARK: - Private Funcs |
| 148 | + |
| 149 | + private func urlWithName(_ name: String) -> String { |
| 150 | + assert(!name.isEmpty, "Name cannot be empty") |
| 151 | + |
| 152 | + // Check if we're using the emulator |
| 153 | + if let emulatorOrigin = emulatorOrigin { |
| 154 | + return "\(emulatorOrigin)/\(projectID)/\(region)/\(name)" |
| 155 | + } |
| 156 | + |
| 157 | + // Check the custom domain. |
| 158 | + if let customDomain = customDomain { |
| 159 | + return "\(customDomain)/\(name)" |
| 160 | + } |
| 161 | + |
| 162 | + return "https://\(region)-\(projectID).cloudfunctions.net/\(name)" |
| 163 | + } |
| 164 | + |
| 165 | + internal func callFunction(name: String, |
| 166 | + withObject data: Any?, |
| 167 | + timeout: TimeInterval, |
| 168 | + completion: @escaping ((Result<HTTPSCallableResult, Error>) -> Void)) { |
| 169 | + // Get context first. |
| 170 | + contextProvider.getContext { context, error in |
| 171 | + // Note: context is always non-nil since some checks could succeed, we're only failing if |
| 172 | + // there's an error. |
| 173 | + if let error = error { |
| 174 | + completion(.failure(error)) |
| 175 | + } else { |
| 176 | + self.callFunction(name: name, |
| 177 | + withObject: data, |
| 178 | + timeout: timeout, |
| 179 | + context: context, |
| 180 | + completion: completion) |
| 181 | + } |
| 182 | + } |
| 183 | + |
| 184 | + } |
| 185 | + |
| 186 | + private func callFunction(name: String, |
| 187 | + withObject data: Any?, |
| 188 | + timeout: TimeInterval, |
| 189 | + context: FunctionsContext, |
| 190 | + completion: @escaping ((Result<HTTPSCallableResult, Error>) -> Void)) { |
| 191 | + let url = URL(string: urlWithName(name))! |
| 192 | + let request = URLRequest(url: url, |
| 193 | + cachePolicy: .useProtocolCachePolicy, |
| 194 | + timeoutInterval: timeout) |
| 195 | + let fetcher = fetcherService.fetcher(with: request) |
| 196 | + let body = NSMutableDictionary() |
| 197 | + |
| 198 | + // Encode the data in the body. |
| 199 | + var localData = data |
| 200 | + if data == nil { |
| 201 | + localData = NSNull() |
| 202 | + } |
| 203 | + // Force unwrap to match the old invalid argument thrown. |
| 204 | + let encoded = try! serializer.encode(localData!) |
| 205 | + body["data"] = encoded |
| 206 | + |
| 207 | + do { |
| 208 | + let payload = try JSONSerialization.data(withJSONObject: body) |
| 209 | + fetcher.bodyData = payload |
| 210 | + } catch { |
| 211 | + DispatchQueue.main.async { |
| 212 | + completion(.failure(error)) |
| 213 | + } |
| 214 | + return |
| 215 | + } |
| 216 | + |
| 217 | + // Set the headers. |
| 218 | + fetcher.setRequestValue("application/json", forHTTPHeaderField: "Content-Type") |
| 219 | + if let authToken = context.authToken { |
| 220 | + let value = "Bearer \(authToken)" |
| 221 | + fetcher.setRequestValue(value, forHTTPHeaderField: "Authorization") |
| 222 | + } |
| 223 | + |
| 224 | + if let fcmToken = context.fcmToken { |
| 225 | + fetcher.setRequestValue(fcmToken, forHTTPHeaderField: Constants.fcmTokenHeader) |
| 226 | + } |
| 227 | + |
| 228 | + if let appCheckToken = context.appCheckToken { |
| 229 | + fetcher.setRequestValue(appCheckToken, forHTTPHeaderField: Constants.appCheckTokenHeader) |
| 230 | + } |
| 231 | + |
| 232 | + // Override normal security rules if this is a local test. |
| 233 | + if emulatorOrigin != nil { |
| 234 | + fetcher.allowLocalhostRequest = true |
| 235 | + fetcher.allowedInsecureSchemes = ["http"] |
| 236 | + } |
| 237 | + |
| 238 | + fetcher.beginFetch { data, error in |
| 239 | + // If there was an HTTP error, convert it to our own error domain. |
| 240 | + var localError: Error? = nil |
| 241 | + if let error = error as NSError? { |
| 242 | + if error.domain == kGTMSessionFetcherStatusDomain { |
| 243 | + localError = FunctionsErrorForResponse(status: error.code, body: data, serializer: self.serializer) |
| 244 | + } else if error.domain == NSURLErrorDomain, error.code == NSURLErrorTimedOut { |
| 245 | + localError = FunctionsErrorCode.deadlineExceeded.generatedError(userInfo: nil) |
| 246 | + } |
| 247 | + } else { |
| 248 | + // If there wasn't an HTTP error, see if there was an error in the body. |
| 249 | + localError = FunctionsErrorForResponse(status: 200, body: data, serializer: self.serializer) |
| 250 | + } |
| 251 | + |
| 252 | + // If there was an error, report it to the user and stop. |
| 253 | + if let localError = localError { |
| 254 | + completion(.failure(localError)) |
| 255 | + return |
| 256 | + } |
| 257 | + |
| 258 | + // Porting: this check is new since we didn't previously check if `data` was nil. |
| 259 | + guard let data = data else { |
| 260 | + completion(.failure(FunctionsErrorCode.internal.generatedError(userInfo: nil))) |
| 261 | + return |
| 262 | + } |
| 263 | + |
| 264 | + let responseJSONObject: Any |
| 265 | + do { |
| 266 | + responseJSONObject = try JSONSerialization.jsonObject(with: data) |
| 267 | + } catch { |
| 268 | + completion(.failure(error)) |
| 269 | + return |
| 270 | + } |
| 271 | + |
| 272 | + guard let responseJSON = responseJSONObject as? NSDictionary else { |
| 273 | + let userInfo = [NSLocalizedDescriptionKey: "Response was not a dictionary."] |
| 274 | + completion(.failure(FunctionsErrorCode.internal.generatedError(userInfo: userInfo))) |
| 275 | + return |
| 276 | + } |
| 277 | + |
| 278 | + // TODO(klimt): Allow "result" instead of "data" for now, for backwards compatibility. |
| 279 | + let dataJSON = responseJSON["data"] ?? responseJSON["result"] |
| 280 | + guard let dataJSON = dataJSON as AnyObject? else { |
| 281 | + let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."] |
| 282 | + completion(.failure(FunctionsErrorCode.internal.generatedError(userInfo: userInfo))) |
| 283 | + return |
| 284 | + } |
| 285 | + |
| 286 | + let resultData: Any? |
| 287 | + do { |
| 288 | + resultData = try self.serializer.decode(dataJSON) |
| 289 | + } catch { |
| 290 | + completion(.failure(error)) |
| 291 | + return |
| 292 | + } |
| 293 | + |
| 294 | + // TODO: Force unwrap... gross |
| 295 | + let result = HTTPSCallableResult(data: resultData!) |
| 296 | +#warning("This copied comment appears to be incorrect - it's impossible to have a nil callable result.") |
| 297 | + // If there's no result field, this will return nil, which is fine. |
| 298 | + completion(.success(result)) |
| 299 | + } |
| 300 | + } |
| 301 | +} |
0 commit comments