Skip to content

Add FileDownloadDelegate for simple file downloads #275

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Sep 10, 2020
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,35 @@ httpClient.execute(request: request, delegate: delegate).futureResult.whenSucces
}
```

### File downloads

Based on the `HTTPClientResponseDelegate` example above you can build more complex delegates,
the built-in `FileDownloadDelegate` is one of them. It allows streaming the downloaded data
asynchronously, while reporting the download progress at the same time, like in the following
example:

```swift
let client = HTTPClient(eventLoopGroupProvider: .createNew)
let request = try HTTPClient.Request(
url: "https://swift.org/builds/development/ubuntu1804/latest-build.yml"
)

let delegate = try FileDownloadDelegate(path: "/tmp/latest-build.yml", reportProgress: {
if let totalSize = $0 {
print("Total bytes count: \(totalSize)")
}
print("Downloaded \($1) bytes so far")
})

client.execute(request: request, delegate: delegate).futureResult
.whenSuccess { finalTotalBytes, downloadedBytes in
if let totalSize = $0 {
print("Final total bytes count: \(totalSize)")
}
print("Downloaded finished with \($1) bytes downloaded")
}
```

### Unix Domain Socket Paths
Connecting to servers bound to socket paths is easy:
```swift
Expand Down
121 changes: 121 additions & 0 deletions Sources/AsyncHTTPClient/FileDownloadDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the AsyncHTTPClient open source project
//
// Copyright (c) 2020 Apple Inc. and the AsyncHTTPClient project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import NIO
import NIOHTTP1

/// Handles a streaming download to a given file path, allowing headers and progress to be reported.
public final class FileDownloadDelegate: HTTPClientResponseDelegate {
/// The response type for this delegate: the total count of bytes as reported by the response
/// "Content-Length" header (if available) and the count of bytes downloaded.
public struct Progress {
public var totalBytes: Int?
public var receivedBytes: Int
}

private var progress = Progress(totalBytes: nil, receivedBytes: 0)

public typealias Response = Progress

private let filePath: String
private let io: NonBlockingFileIO
private let reportHeaders: ((HTTPHeaders) -> Void)?
private let reportProgress: ((Progress) -> Void)?

private var fileHandleFuture: EventLoopFuture<NIOFileHandle>?
private var writeFuture: EventLoopFuture<Void>?

/// Initializes a new file download delegate.
/// - parameters:
/// - path: Path to a file you'd like to write the download to.
/// - pool: A thread pool to use for asynchronous file I/O.
/// - reportHeaders: A closure called when the response headers are available.
/// - reportProgress: A closure called when a body chunk has been downloaded, with
/// the total byte count and download byte count passed to it as arguments. The callbacks
/// will be invoked in the same threading context that the delegate itself is invoked,
/// as controlled by `EventLoopPreference`.
public init(
path: String,
pool: NIOThreadPool = NIOThreadPool(numberOfThreads: 1),
reportHeaders: ((HTTPHeaders) -> Void)? = nil,
reportProgress: ((Progress) -> Void)? = nil
) throws {
pool.start()
self.io = NonBlockingFileIO(threadPool: pool)
self.filePath = path

self.reportHeaders = reportHeaders
self.reportProgress = reportProgress
}

public func didReceiveHead(
task: HTTPClient.Task<Response>,
_ head: HTTPResponseHead
) -> EventLoopFuture<Void> {
self.reportHeaders?(head.headers)

if let totalBytesString = head.headers.first(name: "Content-Length"),
let totalBytes = Int(totalBytesString) {
self.progress.totalBytes = totalBytes
}

return task.eventLoop.makeSucceededFuture(())
}

public func didReceiveBodyPart(
task: HTTPClient.Task<Response>,
_ buffer: ByteBuffer
) -> EventLoopFuture<Void> {
self.progress.receivedBytes += buffer.readableBytes
self.reportProgress?(self.progress)

let writeFuture: EventLoopFuture<Void>
if let fileHandleFuture = self.fileHandleFuture {
writeFuture = fileHandleFuture.flatMap {
self.io.write(fileHandle: $0, buffer: buffer, eventLoop: task.eventLoop)
}
} else {
let fileHandleFuture = self.io.openFile(
path: self.filePath,
mode: .write,
flags: .allowFileCreation(),
eventLoop: task.eventLoop
)
self.fileHandleFuture = fileHandleFuture
writeFuture = fileHandleFuture.flatMap {
self.io.write(fileHandle: $0, buffer: buffer, eventLoop: task.eventLoop)
}
}

self.writeFuture = writeFuture
return writeFuture
}

private func close(fileHandle: NIOFileHandle) {
try! fileHandle.close()
self.fileHandleFuture = nil
}

public func didFinishRequest(task: HTTPClient.Task<Response>) throws -> Response {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test on the error path? I think we're missing cleanup in the didReceiveError case.

if let writeFuture = self.writeFuture {
writeFuture.whenComplete { _ in
self.fileHandleFuture?.whenSuccess(self.close(fileHandle:))
self.writeFuture = nil
}
} else {
self.fileHandleFuture?.whenSuccess(self.close(fileHandle:))
}
return self.progress
}
}
24 changes: 23 additions & 1 deletion Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,27 @@ enum TemporaryFileHelpers {
}
return try body(shortEnoughPath)
}

/// This function creates a filename that can be used as a temporary file.
internal static func withTemporaryFilePath<T>(
directory: String = temporaryDirectory,
_ body: (String) throws -> T
) throws -> T {
let (fd, path) = self.openTemporaryFile()
close(fd)
try! FileManager.default.removeItem(atPath: path)

defer {
if FileManager.default.fileExists(atPath: path) {
try? FileManager.default.removeItem(atPath: path)
}
}
return try body(path)
}

internal static func fileSize(path: String) throws -> Int? {
return try FileManager.default.attributesOfItem(atPath: path)[.size] as? Int
}
}

internal final class HTTPBin {
Expand Down Expand Up @@ -531,7 +552,8 @@ internal final class HttpBinHandler: ChannelInboundHandler {
context.writeAndFlush(wrapOutboundOut(.head(HTTPResponseHead(version: HTTPVersion(major: 1, minor: 1), status: .ok))), promise: nil)
return
case "/events/10/1": // TODO: parse path
context.write(wrapOutboundOut(.head(HTTPResponseHead(version: HTTPVersion(major: 1, minor: 1), status: .ok))), promise: nil)
let headers = HTTPHeaders([("Content-Length", "50")])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the behaviour of this endpoint (it previously automatically sent chunked encoding, now it doesn't). I don't think any of the tests rely on that behaviour, but as it's fairly cheap to add new endpoints I think I'd rather we added a new one of these.

We can keep the common code by factoring this out into a method that conditionally adds content-length if we want.

context.write(wrapOutboundOut(.head(HTTPResponseHead(version: HTTPVersion(major: 1, minor: 1), status: .ok, headers: headers))), promise: nil)
for i in 0..<10 {
let msg = "id: \(i)"
var buf = context.channel.allocator.buffer(capacity: msg.count)
Expand Down
1 change: 1 addition & 0 deletions Tests/AsyncHTTPClientTests/HTTPClientTests+XCTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ extension HTTPClientTests {
("testPercentEncodedBackslash", testPercentEncodedBackslash),
("testMultipleContentLengthHeaders", testMultipleContentLengthHeaders),
("testStreaming", testStreaming),
("testFileDownload", testFileDownload),
("testRemoteClose", testRemoteClose),
("testReadTimeout", testReadTimeout),
("testConnectTimeout", testConnectTimeout),
Expand Down
23 changes: 23 additions & 0 deletions Tests/AsyncHTTPClientTests/HTTPClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,29 @@ class HTTPClientTests: XCTestCase {
XCTAssertEqual(10, count)
}

func testFileDownload() throws {
var request = try Request(url: self.defaultHTTPBinURLPrefix + "events/10/1")
request.headers.add(name: "Accept", value: "text/event-stream")

let progress =
try TemporaryFileHelpers.withTemporaryFilePath { path -> FileDownloadDelegate.Progress in
let delegate = try FileDownloadDelegate(path: path)

let progress = try self.defaultClient.execute(
request: request,
delegate: delegate
)
.wait()

try XCTAssertEqual(50, TemporaryFileHelpers.fileSize(path: path))

return progress
}

XCTAssertEqual(50, progress.totalBytes)
XCTAssertEqual(50, progress.receivedBytes)
}

func testRemoteClose() throws {
XCTAssertThrowsError(try self.defaultClient.get(url: self.defaultHTTPBinURLPrefix + "close").wait(), "Should fail") { error in
guard case let error = error as? HTTPClientError, error == .remoteConnectionClosed else {
Expand Down