Skip to content

Commit f4d678b

Browse files
committed
Cleanup
Project clean up.
1 parent 9fcd91e commit f4d678b

File tree

3 files changed

+252
-12
lines changed

3 files changed

+252
-12
lines changed

FirebaseFunctions/Sources/Functions.swift

Lines changed: 162 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,105 @@ enum FunctionsConstants {
466466
}
467467
}
468468
}
469+
470+
@available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *)
471+
func stream(at url: URL,
472+
withObject data: Any?,
473+
options: HTTPSCallableOptions?,
474+
timeout: TimeInterval) async throws
475+
-> AsyncThrowingStream<HTTPSCallableResult, Error> {
476+
let context = try await contextProvider.context(options: options)
477+
let fetcher = try makeFetcherForStreamableContent(
478+
url: url,
479+
data: data,
480+
options: options,
481+
timeout: timeout,
482+
context: context
483+
)
484+
485+
do {
486+
let rawData = try await fetcher.beginFetch()
487+
return try callableResultFromResponseAsync(data: rawData, error: nil)
488+
} catch {
489+
// This method always throws when `error` is not `nil`, but ideally,
490+
// it should be refactored so it looks less confusing.
491+
return try callableResultFromResponseAsync(data: nil, error: error)
492+
}
493+
}
494+
495+
@available(iOS 13.0, *)
496+
func callableResultFromResponseAsync(data: Data?,
497+
error: Error?) throws -> AsyncThrowingStream<
498+
HTTPSCallableResult, Error
499+
500+
> {
501+
let processedData =
502+
try processResponseDataForStreamableContent(
503+
from: data,
504+
error: error
505+
)
506+
507+
return processedData
508+
}
469509

510+
private func makeFetcherForStreamableContent(url: URL,
511+
data: Any?,
512+
options: HTTPSCallableOptions?,
513+
timeout: TimeInterval,
514+
context: FunctionsContext) throws -> GTMSessionFetcher {
515+
let request = URLRequest(
516+
url: url,
517+
cachePolicy: .useProtocolCachePolicy,
518+
timeoutInterval: timeout
519+
)
520+
let fetcher = fetcherService.fetcher(with: request)
521+
522+
let data = data ?? NSNull()
523+
let encoded = try serializer.encode(data)
524+
let body = ["data": encoded]
525+
let payload = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed])
526+
fetcher.bodyData = payload
527+
528+
// Set the headers for starting a streaming session.
529+
fetcher.setRequestValue("application/json", forHTTPHeaderField: "Content-Type")
530+
fetcher.setRequestValue("text/event-stream", forHTTPHeaderField: "Accept")
531+
fetcher.request?.httpMethod = "POST"
532+
if let authToken = context.authToken {
533+
let value = "Bearer \(authToken)"
534+
fetcher.setRequestValue(value, forHTTPHeaderField: "Authorization")
535+
}
536+
537+
if let fcmToken = context.fcmToken {
538+
fetcher.setRequestValue(fcmToken, forHTTPHeaderField: Constants.fcmTokenHeader)
539+
}
540+
541+
if options?.requireLimitedUseAppCheckTokens == true {
542+
if let appCheckToken = context.limitedUseAppCheckToken {
543+
fetcher.setRequestValue(
544+
appCheckToken,
545+
forHTTPHeaderField: Constants.appCheckTokenHeader
546+
)
547+
}
548+
} else if let appCheckToken = context.appCheckToken {
549+
fetcher.setRequestValue(
550+
appCheckToken,
551+
forHTTPHeaderField: Constants.appCheckTokenHeader
552+
)
553+
}
554+
// Remove after genStream is updated on the emulator or deployed
555+
#if DEBUG
556+
fetcher.allowLocalhostRequest = true
557+
fetcher.allowedInsecureSchemes = ["http"]
558+
#endif
559+
// Override normal security rules if this is a local test.
560+
if emulatorOrigin != nil {
561+
fetcher.allowLocalhostRequest = true
562+
fetcher.allowedInsecureSchemes = ["http"]
563+
}
564+
565+
return fetcher
566+
}
567+
470568
private func makeFetcher(url: URL,
471569
data: Any?,
472570
options: HTTPSCallableOptions?,
@@ -561,21 +659,73 @@ enum FunctionsConstants {
561659
// Case 4: `error` is `nil`; `data` is not `nil`; `data` doesn’t specify an error -> OK
562660
return data
563661
}
662+
663+
@available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *)
664+
private func processResponseDataForStreamableContent(from data: Data?,
665+
error: Error?) throws -> AsyncThrowingStream<
666+
HTTPSCallableResult,
667+
Error
668+
> {
669+
670+
return AsyncThrowingStream { continuation in
671+
Task {
672+
var resultArray = [String]()
673+
do {
674+
if let error = error {
675+
throw error
676+
}
677+
678+
guard let data = data else {
679+
throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil)
680+
}
681+
682+
if let dataChunk = String(data: data, encoding: .utf8) {
683+
// We remove the "data :" field so it can be safely parsed to Json.
684+
let dataChunkToJson = dataChunk.split(separator: "\n").map {
685+
String($0.dropFirst(6))
686+
}
687+
resultArray.append(contentsOf: dataChunkToJson)
688+
} else {
689+
throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil)
690+
}
691+
692+
for dataChunk in resultArray {
693+
let json = try callableResultFromResponse(
694+
data: dataChunk.data(using: .utf8, allowLossyConversion: true),
695+
error: error
696+
)
697+
continuation.yield(HTTPSCallableResult(data: json.data))
698+
}
699+
700+
continuation.onTermination = { @Sendable _ in
701+
// Callback for cancelling the stream
702+
continuation.finish()
703+
}
704+
// Close the stream once it's done
705+
continuation.finish()
706+
} catch {
707+
continuation.finish(throwing: error)
708+
}
709+
}
710+
}
711+
}
564712

565713
private func responseDataJSON(from data: Data) throws -> Any {
566-
let responseJSONObject = try JSONSerialization.jsonObject(with: data)
714+
let responseJSONObject = try JSONSerialization.jsonObject(with: data)
567715

568-
guard let responseJSON = responseJSONObject as? NSDictionary else {
569-
let userInfo = [NSLocalizedDescriptionKey: "Response was not a dictionary."]
570-
throw FunctionsError(.internal, userInfo: userInfo)
571-
}
716+
guard let responseJSON = responseJSONObject as? NSDictionary else {
717+
let userInfo = [NSLocalizedDescriptionKey: "Response was not a dictionary."]
718+
throw FunctionsError(.internal, userInfo: userInfo)
719+
}
572720

573-
// `result` is checked for backwards compatibility:
574-
guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] else {
575-
let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."]
576-
throw FunctionsError(.internal, userInfo: userInfo)
721+
// `result` is checked for backwards compatibility,
722+
// `message` is checked for StramableContent:
723+
guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] ?? responseJSON["message"]
724+
else {
725+
let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."]
726+
throw FunctionsError(.internal, userInfo: userInfo)
727+
}
728+
729+
return dataJSON
577730
}
578-
579-
return dataJSON
580-
}
581731
}

FirebaseFunctions/Sources/HTTPSCallable.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,10 @@ open class HTTPSCallable: NSObject {
133133
try await functions
134134
.callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval)
135135
}
136+
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
137+
open func stream(_ data: Any? = nil) async throws
138+
-> AsyncThrowingStream<HTTPSCallableResult, Error> {
139+
try await functions
140+
.stream(at: url, withObject: data, options: options, timeout: timeoutInterval)
141+
}
136142
}

FirebaseFunctions/Tests/Unit/FunctionsTests.swift

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,4 +358,88 @@ class FunctionsTests: XCTestCase {
358358
}
359359
waitForExpectations(timeout: 1.5)
360360
}
361+
362+
func testGenerateStreamContent() async {
363+
let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true)
364+
var response = [String]()
365+
366+
let input: [String: Any] = ["data": "Why is the sky blue"]
367+
do {
368+
let stream = try await functions?.stream(
369+
at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!,
370+
withObject: input,
371+
options: options,
372+
timeout: 4.0
373+
)
374+
//Fisrt chunk of the stream comes as NSDictionary
375+
if let stream = stream {
376+
for try await result in stream {
377+
if let dataChunk = result.data as? NSDictionary {
378+
for (key, value) in dataChunk {
379+
response.append("\(key) \(value)")
380+
}
381+
} else {
382+
// Last chunk is a the concatened result so we have to parse it as String else will
383+
// fail.
384+
if (result.data as? String) != nil {
385+
response.append(result.data as! String)
386+
}
387+
}
388+
}
389+
XCTAssertEqual(
390+
response,
391+
[
392+
"chunk hello",
393+
"chunk world",
394+
"chunk this",
395+
"chunk is",
396+
"chunk cool",
397+
"hello world this is cool",
398+
]
399+
)
400+
}
401+
} catch {
402+
XCTExpectFailure("Failed to download stream: \(error)")
403+
}
404+
}
405+
406+
func testGenerateStreamContentCanceled() async {
407+
var response = [String]()
408+
let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true)
409+
let input: [String: Any] = ["data": "Why is the sky blue"]
410+
411+
let task = Task.detached { [self] in
412+
let stream = try await functions?.stream(
413+
at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!,
414+
withObject: input,
415+
options: options,
416+
timeout: 4.0
417+
)
418+
// Fisrt chunk of the stream comes as NSDictionary
419+
if let stream = stream {
420+
for try await result in stream {
421+
if let dataChunk = result.data as? NSDictionary {
422+
for (key, value) in dataChunk {
423+
response.append("\(key) \(value)")
424+
}
425+
// Last chunk is a the concatened result so we have to parse it as String else will
426+
// fail.
427+
} else {
428+
if (result.data as? String) != nil {
429+
response.append(result.data as! String)
430+
}
431+
}
432+
}
433+
// Since we cancel the call we are expecting an empty array.
434+
XCTAssertEqual(
435+
response,
436+
[]
437+
)
438+
}
439+
}
440+
// We cancel the task and we expect a nul respone even if the stream was initiaded.
441+
task.cancel()
442+
let result = await task.result
443+
XCTAssertNotNil(result)
444+
}
361445
}

0 commit comments

Comments
 (0)