0

Does anyone have experience opening HTTP stream on iOS? I have tried multiple solutions without any luck (examples bellow).

For better context, here's example of endpoint that will stream values (as ndjson) upon opening connection:

GET /v2/path/{id}
Accept: application/x-ndjson

Attempt #1:

Issue: The completion handler is never called

let keyID = try keyAdapter.getKeyID(for: .signHash)
let url = baseURL.appendingPathComponent("/v2/path/\(keyID)")

var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "GET"
urlRequest.setValue("application/x-ndjson", forHTTPHeaderField: "Accept")

session.dataTask(with: urlRequest) { data, response, error in
   // This never gets called.
   // I would expect that the completion is called every time backend emits new value.
}.resume()

Attempt #2:

Issue: Debugger displays this message: Connection 0: encountered error(12:1)

private var stream: URLSessionStreamTask? = nil

func startStream() {
    let keyID = try keyAdapter.getKeyID(for: .signHash)
    let url = baseURL.appendingPathComponent("/v2/path/\(keyID)")

    let stream = session.streamTask(withHostName: url, port: 443)
    // Not sure how to set headers. 
    // Header needs to be set so backend knows client wants to connect a stream.
    self.stream = stream

    stream.startSecureConnection()
    startRead(stream: stream)
}

private func startRead(stream: URLSessionStreamTask) {
    stream.readData(ofMinLength: 1, maxLength: 4096, timeout: 120.0) { data, endOfFile, error in
        if let error = error {
            Logger.shared.log(level: .error, "Reading data from stream failed with error: \(error.localizedDescription)")
        } else if let data = data {
            Logger.shared.log(level: .error, "Received data from stream (\(data.count)B)")
            if !endOfFile {
                self.startRead(stream: stream)
            } else {
                Logger.shared.log(level: .info, "End of file")
            }
        } else {
            Logger.shared.log(level: .error, "Reading stream endup in unspecified state (both data and error are nil).")
        }
    }
}

Does anyone have experience with this? How can I keep HTTP connection open and listen to a new values that backend is streaming?

Lachtan
  • 4,803
  • 6
  • 28
  • 34
  • Is it really a `streamTask` you need? Since you put "443" port, but it's not specified in the doc, nor in the `dataTask` part... The `dataTask` with closure will be called only when the Task "ends". What about using a `DataTask`, but with the `delegate` instead of closure, it might grabs little by little the data you receive? – Larme Jun 15 '22 at 12:10
  • @Larme : Port 443 is set because it's HTTP request (and HTTP requests are using port 443 by default). I have found a solution - feel free to check answer bellow :) – Lachtan Jun 16 '22 at 15:55
  • Doesn't what I suggested which seems to be equivalent to URLConnecion, but with modern URLSession https://pastebin.com/FbkTwwvF works? – Larme Jun 16 '22 at 16:43
  • @Larme unfortunately I didn’t manage to make it work with URLSession – Lachtan Jun 17 '22 at 17:19

2 Answers2

0

iOS can connect to HTTP stream using now deprecated API URLConnection. The API was deprecated in iOS 9, however it's still available for use (and will be in iOS 16 - tested).

First you need to create URLRequest and setup the NSURLConnection:

let url = URL(string: "\(baseURL)/v2/path/\(keyID)")!

var urlRequest = URLRequest(url: url)
urlRequest.setValue("application/x-ndjson", forHTTPHeaderField: "Accept")

let connnection = NSURLConnection(request: urlRequest, delegate: self, startImmediately: true)
connnection?.start()

Notice that the argument for delegate in the code above is of type Any which doesn't help to figure out what protocol(s) to implement. There are two - NSURLConnectionDelegate and NSURLConnectionDataDelegate.

Let's receive data:

public func connection(_ connection: NSURLConnection, didReceive data: Data) {
    let string = String(data: data, encoding: .utf8)
    Logger.shared.log(level: .debug, "didReceive data:\n\(string ?? "N/A")")
}

Then implement a method for catching errors:

public func connection(_ connection: NSURLConnection, didFailWithError error: Error) {
    Logger.shared.log(level: .debug, "didFailWithError: \(error)")
}

And if you have custom SSL pinning, then:

public func connection(_ connection: NSURLConnection, willSendRequestFor challenge: URLAuthenticationChallenge) {
    guard let certificate = certificate, let identity = identity else {
        Logger.shared.log(level: .info, "No credentials set. Using default handling. (certificate and/or identity are nil)")
        challenge.sender?.performDefaultHandling?(for: challenge)
        return
    }

    let credential = URLCredential(identity: identity, certificates: [certificate], persistence: .forSession)
    challenge.sender?.use(credential, for: challenge)
}

There is not much info on the internet, so hopefully it will save someone days of trial and error.

Lachtan
  • 4,803
  • 6
  • 28
  • 34
0

I was looking for the same solution today. At first, I tried to use session.streamTask, but I didn't know how to use it. It's a low-level task for TCP, but what I wanted was an HTTP-level solution. I also didn't want to use URLConnection, which has been deprecated.

After some research, I finally figured it out: In the documentation for URLSessionDataDelegate, https://developer.apple.com/documentation/foundation/urlsessiondatadelegate

A URLSession object need not have a delegate. If no delegate is assigned, when you create tasks in that session, you must provide a completion handler block to obtain the data.

Completion handler blocks are primarily intended as an alternative to using a custom delegate. If you create a task using a method that takes a completion handler block, the delegate methods for response and data delivery are not called.

The key is to not set a completion handler block in dataTask(), and implement the 2 delegate methods of URLSessionDataDelegate:

// This will be triggered repeatedly when new data comes
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive: Data) {
    var resultString = String(data: didReceive, encoding: .utf8)
    print("didReceive: \(resultString)")
}
    
// This will be triggered when the task ends. Handle errors here as well
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
    print("didCompleteWithError: \(error)")
}

Another key is to set the delegate to the URLSessionDataTask, not URLSession. The problem with Larme's code is that he set the delegate to URLSession, so the function urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive: Data) will not be called.

The full code demonstration:

class NetRequest: NSObject, URLSessionDataDelegate {

    func startRequest() {
        var urlRequest = URLRequest(url: "http://...")
        // Set up urlRequest...
        // ...
        
        let session = URLSession(configuration: .default)
        let dataTask = session.dataTask(with: urlRequest)
        dataTask.delegate = self
        dataTask.resume()
    }

    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive: Data) {
        var resultString = String(data: didReceive, encoding: .utf8)
        print("didReceive: \(resultString)")
    }
    
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        print("didCompleteWithError: \(error)")
    }

}

Alex
  • 124
  • 1
  • 4