Files
firebase-ios-sdk/FirebaseFunctions/Sources/Functions.swift
T

380 lines
14 KiB
Swift

// Copyright 2022 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import FirebaseAppCheckInterop
import FirebaseAuthInterop
import FirebaseCore
import FirebaseCoreInternal
import FirebaseMessagingInterop
import FirebaseSharedSwift
#if COCOAPODS
import GTMSessionFetcher
#else
import GTMSessionFetcherCore
#endif
/// File specific constants.
private enum Constants {
static let appCheckTokenHeader = "X-Firebase-AppCheck"
static let fcmTokenHeader = "Firebase-Instance-ID-Token"
}
/// Cross SDK constants.
internal enum FunctionsConstants {
static let defaultRegion = "us-central1"
}
/**
* `Functions` is the client for Cloud Functions for a Firebase project.
*/
@objc(FIRFunctions) open class Functions: NSObject {
// MARK: - Private Variables
/// The network client to use for http requests.
private let fetcherService: GTMSessionFetcherService
// The projectID to use for all function references.
private let projectID: String
// A serializer to encode/decode data and return values.
private let serializer = FUNSerializer()
// A factory for getting the metadata to include with function calls.
private let contextProvider: FunctionsContextProvider
// The custom domain to use for all functions references (optional).
internal let customDomain: String?
// The region to use for all function references.
internal let region: String
// MARK: - Public APIs
/**
* The current emulator origin, or nil if it is not set.
*/
open private(set) var emulatorOrigin: String?
/**
* Creates a Cloud Functions client with the given app and region, or returns a pre-existing
* instance if one already exists.
* @param app The app for the Firebase project.
* @param region The region for the http trigger, such as "us-central1".
*/
@objc(functionsForApp:region:) open class func functions(app: FirebaseApp = FirebaseApp.app()!,
region: String) -> Functions {
let provider = ComponentType<FunctionsProvider>.instance(for: FunctionsProvider.self,
in: app.container)
return provider.functions(for: app,
region: region,
customDomain: nil,
type: self)
}
/**
* Creates a Cloud Functions client with the given app and region, or returns a pre-existing
* instance if one already exists.
* @param app The app for the Firebase project.
* @param customDomain A custom domain for the http trigger, such as "https://mydomain.com".
*/
@objc(functionsForApp:customDomain:) open class func functions(app: FirebaseApp = FirebaseApp
.app()!,
customDomain: String? = nil) -> Functions {
let provider = app.container.instance(for: FunctionsProvider.self) as? FunctionsProvider
return provider!.functions(for: app,
region: FunctionsConstants.defaultRegion,
customDomain: customDomain,
type: self)
}
/**
* Creates a reference to the Callable HTTPS trigger with the given name.
* @param name The name of the Callable HTTPS trigger.
*/
@objc(HTTPSCallableWithName:) open func httpsCallable(_ name: String) -> HTTPSCallable {
return HTTPSCallable(functions: self, name: name)
}
/// Creates a reference to the Callable HTTPS trigger with the given name, the type of an `Encodable`
/// request and the type of a `Decodable` response.
/// - Parameter name: The name of the Callable HTTPS trigger
/// - Parameter requestAs: The type of the `Encodable` entity to use for requests to this `Callable`
/// - Parameter responseAs: The type of the `Decodable` entity to use for responses from this `Callable`
/// - Parameter encoder: The encoder instance to use to run the encoding.
/// - Parameter decoder: The decoder instance to use to run the decoding.
open func httpsCallable<Request: Encodable,
Response: Decodable>(_ name: String,
requestAs: Request.Type = Request.self,
responseAs: Response.Type = Response.self,
encoder: FirebaseDataEncoder = FirebaseDataEncoder(
),
decoder: FirebaseDataDecoder = FirebaseDataDecoder(
))
-> Callable<Request, Response> {
return Callable(callable: httpsCallable(name), encoder: encoder, decoder: decoder)
}
/**
* Changes this instance to point to a Cloud Functions emulator running locally.
* See https://firebase.google.com/docs/functions/local-emulator
* @param host The host of the local emulator, such as "localhost".
* @param port The port of the local emulator, for example 5005.
*/
@objc open func useEmulator(withHost host: String, port: Int) {
let prefix = host.hasPrefix("http") ? "" : "http://"
let origin = String(format: "\(prefix)\(host):%li", port)
emulatorOrigin = origin
}
// MARK: - Public APIs only for Objective C
/**
* Creates a Cloud Functions client or returns a pre-existing instance if it already exists.
*/
@objc(functions) open class func __functions() -> Functions {
return functions()
}
/**
* Creates a Cloud Functions client with the given app, or returns a pre-existing
* instance if one already exists.
* @param app The app for the Firebase project.
*/
@objc(functionsForApp:) open class func __functionsForApp(app: FirebaseApp) -> Functions {
return functions(app: app)
}
/**
* Creates a Cloud Functions client with the default app and given region.
* @param region The region for the http trigger, such as "us-central1".
*/
@objc(functionsForRegion:) open class func __functionsForRegion(region: String) -> Functions {
return functions(region: region)
}
/**
* Creates a Cloud Functions client with the given app and region, or returns a pre-existing
* instance if one already exists.
* @param customDomain A custom domain for the http trigger, such as "https://mydomain.com".
*/
@objc(functionsForCustomDomain:) open class func __functionsForCustomDomain(customDomain: String?)
-> Functions {
return functions(customDomain: customDomain)
}
// MARK: - Private Funcs (or Internal for tests)
@objc internal init(projectID: String,
region: String,
customDomain: String?,
auth: AuthInterop?,
messaging: MessagingInterop?,
appCheck: AppCheckInterop?,
fetcherService: GTMSessionFetcherService = GTMSessionFetcherService()) {
self.projectID = projectID
self.region = region
self.customDomain = customDomain
emulatorOrigin = nil
contextProvider = FunctionsContextProvider(auth: auth,
messaging: messaging,
appCheck: appCheck)
self.fetcherService = fetcherService
}
/// Using the component system for initialization.
internal convenience init(app: FirebaseApp,
region: String,
customDomain: String?) {
// TODO: These are not optionals, but they should be.
let auth = ComponentType<AuthInterop>.instance(for: AuthInterop.self, in: app.container)
let messaging = ComponentType<MessagingInterop>.instance(for: MessagingInterop.self,
in: app.container)
let appCheck = ComponentType<AppCheckInterop>.instance(for: AppCheckInterop.self,
in: app.container)
guard let projectID = app.options.projectID else {
fatalError("Firebase Functions requires the projectID to be set in the App's Options.")
}
self.init(projectID: projectID,
region: region,
customDomain: customDomain,
auth: auth,
messaging: messaging,
appCheck: appCheck)
}
internal func urlWithName(_ name: String) -> String {
assert(!name.isEmpty, "Name cannot be empty")
// Check if we're using the emulator
if let emulatorOrigin = emulatorOrigin {
return "\(emulatorOrigin)/\(projectID)/\(region)/\(name)"
}
// Check the custom domain.
if let customDomain = customDomain {
return "\(customDomain)/\(name)"
}
return "https://\(region)-\(projectID).cloudfunctions.net/\(name)"
}
internal func callFunction(name: String,
withObject data: Any?,
timeout: TimeInterval,
completion: @escaping ((Result<HTTPSCallableResult, Error>) -> Void)) {
// Get context first.
contextProvider.getContext { context, error in
// Note: context is always non-nil since some checks could succeed, we're only failing if
// there's an error.
if let error = error {
completion(.failure(error))
} else {
self.callFunction(name: name,
withObject: data,
timeout: timeout,
context: context,
completion: completion)
}
}
}
private func callFunction(name: String,
withObject data: Any?,
timeout: TimeInterval,
context: FunctionsContext,
completion: @escaping ((Result<HTTPSCallableResult, Error>) -> Void)) {
let url = URL(string: urlWithName(name))!
let request = URLRequest(url: url,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: timeout)
let fetcher = fetcherService.fetcher(with: request)
let body = NSMutableDictionary()
// Encode the data in the body.
var localData = data
if data == nil {
localData = NSNull()
}
// Force unwrap to match the old invalid argument thrown.
let encoded = try! serializer.encode(localData!)
body["data"] = encoded
do {
let payload = try JSONSerialization.data(withJSONObject: body)
fetcher.bodyData = payload
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
return
}
// Set the headers.
fetcher.setRequestValue("application/json", forHTTPHeaderField: "Content-Type")
if let authToken = context.authToken {
let value = "Bearer \(authToken)"
fetcher.setRequestValue(value, forHTTPHeaderField: "Authorization")
}
if let fcmToken = context.fcmToken {
fetcher.setRequestValue(fcmToken, forHTTPHeaderField: Constants.fcmTokenHeader)
}
if let appCheckToken = context.appCheckToken {
fetcher.setRequestValue(appCheckToken, forHTTPHeaderField: Constants.appCheckTokenHeader)
}
// Override normal security rules if this is a local test.
if emulatorOrigin != nil {
fetcher.allowLocalhostRequest = true
fetcher.allowedInsecureSchemes = ["http"]
}
fetcher.beginFetch { data, error in
// If there was an HTTP error, convert it to our own error domain.
var localError: Error?
if let error = error as NSError? {
if error.domain == kGTMSessionFetcherStatusDomain {
localError = FunctionsErrorForResponse(
status: error.code,
body: data,
serializer: self.serializer
)
} else if error.domain == NSURLErrorDomain, error.code == NSURLErrorTimedOut {
localError = FunctionsErrorCode.deadlineExceeded.generatedError(userInfo: nil)
}
// If there was an error, report it to the user and stop.
if let localError = localError {
completion(.failure(localError))
} else {
completion(.failure(error))
}
return
} else {
// If there wasn't an HTTP error, see if there was an error in the body.
if let bodyError = FunctionsErrorForResponse(
status: 200,
body: data,
serializer: self.serializer
) {
completion(.failure(bodyError))
return
}
}
// Porting: this check is new since we didn't previously check if `data` was nil.
guard let data = data else {
completion(.failure(FunctionsErrorCode.internal.generatedError(userInfo: nil)))
return
}
let responseJSONObject: Any
do {
responseJSONObject = try JSONSerialization.jsonObject(with: data)
} catch {
completion(.failure(error))
return
}
guard let responseJSON = responseJSONObject as? NSDictionary else {
let userInfo = [NSLocalizedDescriptionKey: "Response was not a dictionary."]
completion(.failure(FunctionsErrorCode.internal.generatedError(userInfo: userInfo)))
return
}
// TODO(klimt): Allow "result" instead of "data" for now, for backwards compatibility.
let dataJSON = responseJSON["data"] ?? responseJSON["result"]
guard let dataJSON = dataJSON as AnyObject? else {
let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."]
completion(.failure(FunctionsErrorCode.internal.generatedError(userInfo: userInfo)))
return
}
let resultData: Any?
do {
resultData = try self.serializer.decode(dataJSON)
} catch {
completion(.failure(error))
return
}
// TODO: Force unwrap... gross
let result = HTTPSCallableResult(data: resultData!)
// TODO: This copied comment appears to be incorrect - it's impossible to have a nil callable result
// If there's no result field, this will return nil, which is fine.
completion(.success(result))
}
}
}