diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 51e405b2f39..34222e6ef66 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -19,9 +19,9 @@ import FirebaseMessagingInterop import FirebaseSharedSwift import Foundation #if COCOAPODS - import GTMSessionFetcher +import GTMSessionFetcher #else - import GTMSessionFetcherCore +import GTMSessionFetcherCore #endif // Avoids exposing internal FirebaseCore APIs to Swift users. @@ -41,7 +41,7 @@ enum FunctionsConstants { /// `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. @@ -50,25 +50,25 @@ enum FunctionsConstants { private let serializer = FunctionsSerializer() /// A factory for getting the metadata to include with function calls. private let contextProvider: FunctionsContextProvider - + /// A map of active instances, grouped by app. Keys are FirebaseApp names and values are arrays /// containing all instances of Functions associated with the given app. private static var instances: [String: [Functions]] = [:] - + /// Lock to manage access to the instances array to avoid race conditions. private static var instancesLock: os_unfair_lock = .init() - + /// The custom domain to use for all functions references (optional). let customDomain: String? - + /// The region to use for all function references. 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 using the default or returns a pre-existing instance if it /// already exists. /// - Returns: A shared Functions instance initialized with the default `FirebaseApp`. @@ -79,7 +79,7 @@ enum FunctionsConstants { customDomain: nil ) } - + /// Creates a Cloud Functions client with the given app, or returns a pre-existing /// instance if one already exists. /// - Parameter app: The app for the Firebase project. @@ -87,7 +87,7 @@ enum FunctionsConstants { @objc(functionsForApp:) open class func functions(app: FirebaseApp) -> Functions { return functions(app: app, region: FunctionsConstants.defaultRegion, customDomain: nil) } - + /// Creates a Cloud Functions client with the default app and given region. /// - Parameter region: The region for the HTTP trigger, such as `us-central1`. /// - Returns: A shared Functions instance initialized with the default `FirebaseApp` and a @@ -95,7 +95,7 @@ enum FunctionsConstants { @objc(functionsForRegion:) open class func functions(region: String) -> Functions { return functions(app: FirebaseApp.app(), region: region, customDomain: nil) } - + /// Creates a Cloud Functions client with the given custom domain or returns a pre-existing /// instance if one already exists. /// - Parameter customDomain: A custom domain for the HTTP trigger, such as @@ -106,7 +106,7 @@ enum FunctionsConstants { return functions(app: FirebaseApp.app(), region: FunctionsConstants.defaultRegion, customDomain: customDomain) } - + /// Creates a Cloud Functions client with the given app and region, or returns a pre-existing /// instance if one already exists. /// - Parameters: @@ -117,7 +117,7 @@ enum FunctionsConstants { region: String) -> Functions { return functions(app: app, region: region, customDomain: nil) } - + /// Creates a Cloud Functions client with the given app and custom domain, or returns a /// pre-existing /// instance if one already exists. @@ -127,17 +127,17 @@ enum FunctionsConstants { /// - Returns: An instance of `Functions` with a custom app and HTTP trigger domain. @objc(functionsForApp:customDomain:) open class func functions(app: FirebaseApp, customDomain: String) - -> Functions { + -> Functions { return functions(app: app, region: FunctionsConstants.defaultRegion, customDomain: customDomain) } - + /// Creates a reference to the Callable HTTPS trigger with the given name. /// - Parameter name: The name of the Callable HTTPS trigger. /// - Returns: A reference to a Callable HTTPS trigger. @objc(HTTPSCallableWithName:) open func httpsCallable(_ name: String) -> HTTPSCallable { HTTPSCallable(functions: self, url: functionURL(for: name)!) } - + /// Creates a reference to the Callable HTTPS trigger with the given name and configuration /// options. /// - Parameters: @@ -146,17 +146,17 @@ enum FunctionsConstants { /// - Returns: A reference to a Callable HTTPS trigger. @objc(HTTPSCallableWithName:options:) public func httpsCallable(_ name: String, options: HTTPSCallableOptions) - -> HTTPSCallable { + -> HTTPSCallable { HTTPSCallable(functions: self, url: functionURL(for: name)!, options: options) } - + /// Creates a reference to the Callable HTTPS trigger with the given name. /// - Parameter url: The URL of the Callable HTTPS trigger. /// - Returns: A reference to a Callable HTTPS trigger. @objc(HTTPSCallableWithURL:) open func httpsCallable(_ url: URL) -> HTTPSCallable { return HTTPSCallable(functions: self, url: url) } - + /// Creates a reference to the Callable HTTPS trigger with the given name and configuration /// options. /// - Parameters: @@ -165,10 +165,10 @@ enum FunctionsConstants { /// - Returns: A reference to a Callable HTTPS trigger. @objc(HTTPSCallableWithURL:options:) public func httpsCallable(_ url: URL, options: HTTPSCallableOptions) - -> HTTPSCallable { + -> HTTPSCallable { return HTTPSCallable(functions: self, url: url, options: options) } - + /// 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. @@ -181,21 +181,21 @@ enum FunctionsConstants { /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud /// Functions invocations. open func httpsCallable(_ name: String, - requestAs: Request.Type = Request.self, - responseAs: Response.Type = Response.self, - encoder: FirebaseDataEncoder = FirebaseDataEncoder( - ), - decoder: FirebaseDataDecoder = FirebaseDataDecoder( - )) - -> Callable { + Response: Decodable>(_ name: String, + requestAs: Request.Type = Request.self, + responseAs: Response.Type = Response.self, + encoder: FirebaseDataEncoder = FirebaseDataEncoder( + ), + decoder: FirebaseDataDecoder = FirebaseDataDecoder( + )) + -> Callable { return Callable( callable: httpsCallable(name), encoder: encoder, decoder: decoder ) } - + /// 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. @@ -209,22 +209,22 @@ enum FunctionsConstants { /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud /// Functions invocations. open func httpsCallable(_ name: String, - options: HTTPSCallableOptions, - requestAs: Request.Type = Request.self, - responseAs: Response.Type = Response.self, - encoder: FirebaseDataEncoder = FirebaseDataEncoder( - ), - decoder: FirebaseDataDecoder = FirebaseDataDecoder( - )) - -> Callable { + Response: Decodable>(_ name: String, + options: HTTPSCallableOptions, + requestAs: Request.Type = Request.self, + responseAs: Response.Type = Response.self, + encoder: FirebaseDataEncoder = FirebaseDataEncoder( + ), + decoder: FirebaseDataDecoder = FirebaseDataDecoder( + )) + -> Callable { return Callable( callable: httpsCallable(name, options: options), encoder: encoder, decoder: decoder ) } - + /// 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. @@ -237,21 +237,21 @@ enum FunctionsConstants { /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud /// Functions invocations. open func httpsCallable(_ url: URL, - requestAs: Request.Type = Request.self, - responseAs: Response.Type = Response.self, - encoder: FirebaseDataEncoder = FirebaseDataEncoder( - ), - decoder: FirebaseDataDecoder = FirebaseDataDecoder( - )) - -> Callable { + Response: Decodable>(_ url: URL, + requestAs: Request.Type = Request.self, + responseAs: Response.Type = Response.self, + encoder: FirebaseDataEncoder = FirebaseDataEncoder( + ), + decoder: FirebaseDataDecoder = FirebaseDataDecoder( + )) + -> Callable { return Callable( callable: httpsCallable(url), encoder: encoder, decoder: decoder ) } - + /// 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. @@ -265,22 +265,22 @@ enum FunctionsConstants { /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud /// Functions invocations. open func httpsCallable(_ url: URL, - options: HTTPSCallableOptions, - requestAs: Request.Type = Request.self, - responseAs: Response.Type = Response.self, - encoder: FirebaseDataEncoder = FirebaseDataEncoder( - ), - decoder: FirebaseDataDecoder = FirebaseDataDecoder( - )) - -> Callable { + Response: Decodable>(_ url: URL, + options: HTTPSCallableOptions, + requestAs: Request.Type = Request.self, + responseAs: Response.Type = Response.self, + encoder: FirebaseDataEncoder = FirebaseDataEncoder( + ), + decoder: FirebaseDataDecoder = FirebaseDataDecoder( + )) + -> Callable { return Callable( callable: httpsCallable(url, options: options), 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 @@ -293,9 +293,9 @@ enum FunctionsConstants { let origin = String(format: "\(prefix)\(host):%li", port) emulatorOrigin = origin } - + // MARK: - Private Funcs (or Internal for tests) - + /// Solely used to have one precondition and one location where we fetch from the container. This /// previously was avoided due to default arguments but that doesn't work well with Obj-C /// compatibility. @@ -305,10 +305,10 @@ enum FunctionsConstants { fatalError("`FirebaseApp.configure()` needs to be called before using Functions.") } os_unfair_lock_lock(&instancesLock) - + // Unlock before the function returns. defer { os_unfair_lock_unlock(&instancesLock) } - + if let associatedInstances = instances[app.name] { for instance in associatedInstances { // Domains may be nil, so handle with care. @@ -329,7 +329,7 @@ enum FunctionsConstants { instances[app.name] = existingInstances + [newInstance] return newInstance } - + @objc init(projectID: String, region: String, customDomain: String?, @@ -346,7 +346,7 @@ enum FunctionsConstants { appCheck: appCheck) self.fetcherService = fetcherService } - + /// Using the component system for initialization. convenience init(app: FirebaseApp, region: String, @@ -357,7 +357,7 @@ enum FunctionsConstants { in: app.container) let appCheck = ComponentType.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.") } @@ -368,23 +368,23 @@ enum FunctionsConstants { messaging: messaging, appCheck: appCheck) } - + func functionURL(for name: String) -> URL? { assert(!name.isEmpty, "Name cannot be empty") - + // Check if we're using the emulator if let emulatorOrigin { return URL(string: "\(emulatorOrigin)/\(projectID)/\(region)/\(name)") } - + // Check the custom domain. if let customDomain { return URL(string: "\(customDomain)/\(name)") } - + return URL(string: "https://\(region)-\(projectID).cloudfunctions.net/\(name)") } - + @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) func callFunction(at url: URL, withObject data: Any?, @@ -398,7 +398,7 @@ enum FunctionsConstants { timeout: timeout, context: context ) - + do { let rawData = try await fetcher.beginFetch() return try callableResult(fromResponseData: rawData) @@ -406,7 +406,7 @@ enum FunctionsConstants { throw processedError(fromResponseError: error) } } - + func callFunction(at url: URL, withObject data: Any?, options: HTTPSCallableOptions?, @@ -428,7 +428,7 @@ enum FunctionsConstants { } } } - + private func callFunction(url: URL, withObject data: Any?, options: HTTPSCallableOptions?, @@ -450,7 +450,7 @@ enum FunctionsConstants { } return } - + fetcher.beginFetch { [self] data, error in let result: Result if let error { @@ -464,13 +464,171 @@ enum FunctionsConstants { } else { result = .failure(FunctionsError(.internal)) } - + DispatchQueue.main.async { completion(result) } } } - + + /// Function to initialize a streamaing event for an HTTPCallable + /// - Parameters: + /// - url: The url of the Callable HTTPS trigger. + /// - data: Object to be sent in the request. + /// - options: The options with which to customize the Callable HTTPS trigger. + /// - timeout: timeout for the HTTPSCallableResult request. + /// - Returns: HTTPSCallableResult Streaming. + @available(iOS 15, *) + func stream(at url: URL, + withObject data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval) + -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + + Task { + // TODO: This API does not throw. Should the throwing request + // setup be in the stream or one level up? + let urlRequest: URLRequest + do { + let context = try await contextProvider.context(options: options) + urlRequest = try makeRequestForStreamableContent( + url: url, + data: data, + options: options, + timeout: timeout, + context: context + ) + + } catch { + continuation.finish(throwing: error) + return + } + + let stream: URLSession.AsyncBytes + let rawResponse: URLResponse + do { + (stream, rawResponse) = try await URLSession.shared.bytes(for: urlRequest) + } catch { + continuation.finish(throwing: error) + return + } + + // Verify the status code an HTTP response + guard let response = rawResponse as? HTTPURLResponse else { + continuation.finish( + throwing: FunctionsError( + .internal, + userInfo: [NSLocalizedDescriptionKey: "Response was not an HTTP response."] + ) + ) + return + } + // Verify the status code is a 200 + guard response.statusCode == 200 else { + continuation.finish( + throwing: FunctionsError( + .internal, + userInfo: [NSLocalizedDescriptionKey: "Response is not a successful 200."] + ) + ) + return + } + + for try await line in stream.lines { + if line.hasPrefix("data:") { + // We can assume 5 characters since it's utf-8 encoded, removing `data:`. + let jsonText = String(line.dropFirst(5)) + let data: Data + do { + data = try jsonData(jsonText: jsonText) + } catch { + continuation.finish(throwing: error) + return + } + + // Handle the content and parse it. + do { + let content = try callableResult(fromResponseData: data) + continuation.yield(content) + } catch { + continuation.finish(throwing: error) + return + } + } else { + continuation.finish( + throwing: FunctionsError( + .internal, + userInfo: [NSLocalizedDescriptionKey: "Unexpected format for streamed response."] + ) + ) + } + } + continuation.finish(throwing: nil) + } + } + } + + private func jsonData(jsonText: String) throws -> Data { + guard let data = jsonText.data(using: .utf8) else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: [], + debugDescription: "Could not parse response as UTF8." + )) + } + return data + } + + + private func makeRequestForStreamableContent(url: URL, + data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval, + context: FunctionsContext) throws + -> URLRequest { + var urlRequest = URLRequest( + url: url, + cachePolicy: .useProtocolCachePolicy, + timeoutInterval: timeout + ) + + let data = data ?? NSNull() + let encoded = try serializer.encode(data) + let body = ["data": encoded] + let payload = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed]) + urlRequest.httpBody = payload + + // Set the headers for starting a streaming session. + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("text/event-stream", forHTTPHeaderField: "Accept") + urlRequest.httpMethod = "POST" + + if let authToken = context.authToken { + let value = "Bearer \(authToken)" + urlRequest.setValue(value, forHTTPHeaderField: "Authorization") + } + + if let fcmToken = context.fcmToken { + urlRequest.setValue(fcmToken, forHTTPHeaderField: Constants.fcmTokenHeader) + } + + if options?.requireLimitedUseAppCheckTokens == true { + if let appCheckToken = context.limitedUseAppCheckToken { + urlRequest.setValue( + appCheckToken, + forHTTPHeaderField: Constants.appCheckTokenHeader + ) + } + } else if let appCheckToken = context.appCheckToken { + urlRequest.setValue( + appCheckToken, + forHTTPHeaderField: Constants.appCheckTokenHeader + ) + } + + return urlRequest + } + private func makeFetcher(url: URL, data: Any?, options: HTTPSCallableOptions?, @@ -482,24 +640,24 @@ enum FunctionsConstants { timeoutInterval: timeout ) let fetcher = fetcherService.fetcher(with: request) - + let data = data ?? NSNull() let encoded = try serializer.encode(data) let body = ["data": encoded] let payload = try JSONSerialization.data(withJSONObject: body) fetcher.bodyData = payload - + // 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 options?.requireLimitedUseAppCheckTokens == true { if let appCheckToken = context.limitedUseAppCheckToken { fetcher.setRequestValue( @@ -513,16 +671,16 @@ enum FunctionsConstants { forHTTPHeaderField: Constants.appCheckTokenHeader ) } - + // Override normal security rules if this is a local test. if emulatorOrigin != nil { fetcher.allowLocalhostRequest = true fetcher.allowedInsecureSchemes = ["http"] } - + return fetcher } - + private func processedError(fromResponseError error: any Error) -> any Error { let error = error as NSError let localError: (any Error)? = if error.domain == kGTMSessionFetcherStatusDomain { @@ -534,10 +692,10 @@ enum FunctionsConstants { } else if error.domain == NSURLErrorDomain, error.code == NSURLErrorTimedOut { FunctionsError(.deadlineExceeded) } else { nil } - + return localError ?? error } - + private func callableResult(fromResponseData data: Data) throws -> HTTPSCallableResult { let processedData = try processedData(fromResponseData: data) let json = try responseDataJSON(from: processedData) @@ -546,30 +704,32 @@ enum FunctionsConstants { // TODO: Remove `as Any` once `decode(_:)` is refactored return HTTPSCallableResult(data: payload as Any) } - + private func processedData(fromResponseData data: Data) throws -> Data { // `data` might specify a custom error. If so, throw the error. if let bodyError = FunctionsError(httpStatusCode: 200, body: data, serializer: serializer) { throw bodyError } - + return data } - + private func responseDataJSON(from data: Data) throws -> Any { let responseJSONObject = try JSONSerialization.jsonObject(with: data) - + guard let responseJSON = responseJSONObject as? NSDictionary else { let userInfo = [NSLocalizedDescriptionKey: "Response was not a dictionary."] throw FunctionsError(.internal, userInfo: userInfo) } - - // `result` is checked for backwards compatibility: - guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] else { + + // `result` is checked for backwards compatibility, + // `message` is checked for StreamableContent: + guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] ?? responseJSON["message"] + else { let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."] throw FunctionsError(.internal, userInfo: userInfo) } - + return dataJSON } } diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index c2281e54866..a15179d4cf9 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -39,7 +39,7 @@ open class HTTPSCallable: NSObject { // The functions client to use for making calls. private let functions: Functions - private let url: URL + let url: URL private let options: HTTPSCallableOptions? @@ -143,4 +143,9 @@ open class HTTPSCallable: NSObject { try await functions .callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval) } + + @available(iOS 15, *) + func stream(_ data: Any? = nil) -> AsyncThrowingStream { + functions.stream(at: url, withObject: data, options: options, timeout: timeoutInterval) + } } diff --git a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift index 5260bd10b2b..dc0255c7bcb 100644 --- a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift +++ b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift @@ -866,6 +866,118 @@ class IntegrationTests: XCTestCase { XCTAssertEqual(response, expected) } } + + @available(iOS 15, *) + func testGenerateStreamContent() async throws { + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + + let input: [String: Any] = ["data": "Why is the sky blue"] + + let stream = functions.stream( + at: emulatorURL("genStream"), + withObject: input, + options: options, + timeout: 4.0 + ) + let result = try await response(from: stream) + XCTAssertEqual( + result, + [ + "chunk hello", + "chunk world", + "chunk this", + "chunk is", + "chunk cool", + "hello world this is cool", + ] + ) + } + + @available(iOS 15, *) + func testGenerateStreamContentCanceled() async { + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + let input: [String: Any] = ["data": "Why is the sky blue"] + + let task = Task.detached { [self] in + let stream = functions.stream( + at: emulatorURL("genStream"), + withObject: input, + options: options, + timeout: 4.0 + ) + + let result = try await response(from: stream) + // Since we cancel the call we are expecting an empty array. + XCTAssertEqual( + result, + [] + ) + } + // We cancel the task and we expect a null response even if the stream was initiated. + task.cancel() + let respone = await task.result + XCTAssertNotNil(respone) + } + + @available(iOS 15, *) + func testGenerateStreamContent_badResponse() async { + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + let input: [String: Any] = ["data": "Why is the sky blue"] + + let task = Task.detached { [self] in + let stream = functions.stream( + at: emulatorURL("genStreams"), + withObject: input, + options: options, + timeout: 4.0 + ) + + let result = try await response(from: stream) + // Since we are sending a bad URL we expect an empty array, the reuqets was not a 200. + XCTAssertEqual( + result, + [] + ) + } + } + + @available(iOS 15, *) + func testGenerateStreamContent_streamErorr() async throws { + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + let input: [String: Any] = ["data": "Why is the sky blue"] + + let task = Task.detached { [self] in + let stream = functions.stream( + at: emulatorURL("genStreamError"), + withObject: input, + options: options, + timeout: 4.0 + ) + + let result = try await response(from: stream) + //TODO FETCH THE ERROR// + } + } + + private func response(from stream: AsyncThrowingStream) async throws -> [String] { + var response = [String]() + for try await result in stream { + // First chunk of the stream comes as NSDictionary + if let dataChunk = result.data as? NSDictionary { + for (key, value) in dataChunk { + response.append("\(key) \(value)") + } + } else { + // Last chunk is the concatenated result so we have to parse it as String else will + // fail. + if let dataString = result.data as? String { + response.append(dataString) + } + } + } + return response + } } private class AuthTokenProvider: AuthInterop { diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 42e684cdf1a..d7aff0e3a74 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -22,7 +22,9 @@ import FirebaseCore import GTMSessionFetcherCore #endif -import SharedTestUtilities +#if SWIFT_PACKAGE + import SharedTestUtilities +#endif import XCTest class FunctionsTests: XCTestCase { @@ -30,7 +32,7 @@ class FunctionsTests: XCTestCase { var functionsCustomDomain: Functions? let fetcherService = GTMSessionFetcherService() let appCheckFake = FIRAppCheckFake() - + override func setUp() { super.setUp() functions = Functions( @@ -47,13 +49,13 @@ class FunctionsTests: XCTestCase { messaging: nil, appCheck: nil, fetcherService: fetcherService) } - + override func tearDown() { functions = nil functionsCustomDomain = nil super.tearDown() } - + func testFunctionsInstanceIsStablePerApp() throws { let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", gcmSenderID: "00000000000000000-00000000000-000000000") @@ -62,30 +64,30 @@ class FunctionsTests: XCTestCase { var functions1 = Functions.functions() var functions2 = Functions.functions(app: FirebaseApp.app()!) XCTAssertEqual(functions1, functions2) - + FirebaseApp.configure(name: "test", options: options) let app2 = try XCTUnwrap(FirebaseApp.app(name: "test")) functions2 = Functions.functions(app: app2, region: "us-central2") XCTAssertNotEqual(functions1, functions2) - + functions1 = Functions.functions(app: app2, region: "us-central2") XCTAssertEqual(functions1, functions2) - + functions1 = Functions.functions(customDomain: "test_domain") functions2 = Functions.functions(region: "us-central1") XCTAssertNotEqual(functions1, functions2) - + functions2 = Functions.functions(app: FirebaseApp.app()!, customDomain: "test_domain") XCTAssertEqual(functions1, functions2) } - + func testFunctionURLForName() throws { XCTAssertEqual( functions?.functionURL(for: "my-endpoint")?.absoluteString, "https://my-region-my-project.cloudfunctions.net/my-endpoint" ) } - + func testFunctionURLForNameEmulator() throws { functionsCustomDomain?.useEmulator(withHost: "localhost", port: 5005) XCTAssertEqual( @@ -93,7 +95,7 @@ class FunctionsTests: XCTestCase { "http://localhost:5005/my-project/my-region/my-endpoint" ) } - + func testFunctionURLForNameRegionWithEmulatorWithScheme() throws { functionsCustomDomain?.useEmulator(withHost: "http://localhost", port: 5005) XCTAssertEqual( @@ -101,19 +103,19 @@ class FunctionsTests: XCTestCase { "http://localhost:5005/my-project/my-region/my-endpoint" ) } - + func testFunctionURLForNameCustomDomain() throws { XCTAssertEqual( functionsCustomDomain?.functionURL(for: "my-endpoint")?.absoluteString, "https://mydomain.com/my-endpoint" ) } - + func testSetEmulatorSettings() throws { functions?.useEmulator(withHost: "localhost", port: 1000) XCTAssertEqual("http://localhost:1000", functions?.emulatorOrigin) } - + /// Test that Functions instances get deallocated. func testFunctionsLifecycle() throws { weak var weakApp: FirebaseApp? @@ -131,9 +133,9 @@ class FunctionsTests: XCTestCase { XCTAssertNil(weakApp) XCTAssertNil(weakFunctions) } - + // MARK: - App Check Integration - + func testCallFunctionWhenUsingLimitedUseAppCheckTokenThenTokenSuccess() { // Given // Stub returns of two different kinds of App Check tokens. Only the @@ -143,7 +145,7 @@ class FunctionsTests: XCTestCase { token: "limited_use_valid_token", error: nil ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in let appCheckTokenHeader = fetcherToTest.request? @@ -153,10 +155,10 @@ class FunctionsTests: XCTestCase { testResponse(nil, "{\"data\":\"May the force be with you!\"}".data(using: .utf8), nil) httpRequestExpectation.fulfill() } - + // When let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - + // Then let completionExpectation = expectation(description: "completionExpectation") functions? @@ -165,15 +167,15 @@ class FunctionsTests: XCTestCase { guard let result = result else { return XCTFail("Unexpected error: \(error!).") } - + XCTAssertEqual(result.data as! String, "May the force be with you!") - + completionExpectation.fulfill() } - + waitForExpectations(timeout: 1.5) } - + func testCallFunctionWhenLimitedUseAppCheckTokenDisabledThenCallWithoutToken() { // Given let limitedUseDummyToken = "limited use dummy token" @@ -181,7 +183,7 @@ class FunctionsTests: XCTestCase { token: limitedUseDummyToken, error: NSError(domain: #function, code: -1) ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in // Assert that header does not contain an AppCheck token. @@ -190,14 +192,14 @@ class FunctionsTests: XCTestCase { XCTAssertNotEqual(value, limitedUseDummyToken) } } - + testResponse(nil, "{\"data\":\"May the force be with you!\"}".data(using: .utf8), nil) httpRequestExpectation.fulfill() } - + // When let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: false) - + // Then let completionExpectation = expectation(description: "completionExpectation") functions? @@ -206,36 +208,36 @@ class FunctionsTests: XCTestCase { guard let result = result else { return XCTFail("Unexpected error: \(error!).") } - + XCTAssertEqual(result.data as! String, "May the force be with you!") - + completionExpectation.fulfill() } - + waitForExpectations(timeout: 1.5) } - + func testCallFunctionWhenLimitedUseAppCheckTokenCannotBeGeneratedThenCallWithoutToken() { // Given appCheckFake.limitedUseTokenResult = FIRAppCheckTokenResultFake( token: "dummy token", error: NSError(domain: #function, code: -1) ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in // Assert that header does not contain an AppCheck token. fetcherToTest.request?.allHTTPHeaderFields?.forEach { key, _ in XCTAssertNotEqual(key, "X-Firebase-AppCheck") } - + testResponse(nil, "{\"data\":\"May the force be with you!\"}".data(using: .utf8), nil) httpRequestExpectation.fulfill() } - + // When let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - + // Then let completionExpectation = expectation(description: "completionExpectation") functions? @@ -244,15 +246,15 @@ class FunctionsTests: XCTestCase { guard let result = result else { return XCTFail("Unexpected error: \(error!).") } - + XCTAssertEqual(result.data as! String, "May the force be with you!") - + completionExpectation.fulfill() } - + waitForExpectations(timeout: 1.5) } - + func testCallFunctionWhenAppCheckIsInstalledAndFACTokenSuccess() { // Stub returns of two different kinds of App Check tokens. Only the // shared use token should be present in Functions's request header. @@ -261,13 +263,13 @@ class FunctionsTests: XCTestCase { token: "limited_use_valid_token", error: nil ) - + let networkError = NSError( domain: "testCallFunctionWhenAppCheckIsInstalled", code: -1, userInfo: nil ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in let appCheckTokenHeader = fetcherToTest.request? @@ -276,7 +278,7 @@ class FunctionsTests: XCTestCase { testResponse(nil, nil, networkError) httpRequestExpectation.fulfill() } - + let completionExpectation = expectation(description: "completionExpectation") functions? .httpsCallable("fake_func") @@ -284,22 +286,22 @@ class FunctionsTests: XCTestCase { guard let error = error else { return XCTFail("Unexpected success: \(result!).") } - + XCTAssertEqual(error as NSError, networkError) - + completionExpectation.fulfill() } - + waitForExpectations(timeout: 1.5) } - + func testAsyncCallFunctionWhenAppCheckIsNotInstalled() async { let networkError = NSError( domain: "testCallFunctionWhenAppCheckIsInstalled", code: -1, userInfo: nil ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in let appCheckTokenHeader = fetcherToTest.request? @@ -308,7 +310,7 @@ class FunctionsTests: XCTestCase { testResponse(nil, nil, networkError) httpRequestExpectation.fulfill() } - + do { _ = try await functionsCustomDomain? .callFunction( @@ -321,17 +323,17 @@ class FunctionsTests: XCTestCase { } catch { XCTAssertEqual(error as NSError, networkError) } - + await fulfillment(of: [httpRequestExpectation], timeout: 1.5) } - + func testCallFunctionWhenAppCheckIsNotInstalled() { let networkError = NSError( domain: "testCallFunctionWhenAppCheckIsInstalled", code: -1, userInfo: nil ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in let appCheckTokenHeader = fetcherToTest.request? @@ -340,7 +342,7 @@ class FunctionsTests: XCTestCase { testResponse(nil, nil, networkError) httpRequestExpectation.fulfill() } - + let completionExpectation = expectation(description: "completionExpectation") functionsCustomDomain?.callFunction( at: URL(string: "https://example.com/fake_func")!, @@ -358,4 +360,6 @@ class FunctionsTests: XCTestCase { } waitForExpectations(timeout: 1.5) } + + // TODO: Implement unit test variants. }