Skip to content

Commit 49a0d30

Browse files
authored
Add FileDownloadDelegate for simple file downloads (swift-server#275)
* Add FileDownloadDelegate for simple file downloads * Add testFileDownload to the allTests array * Fix formatting * Fix compatibility with Swift 5.0 * Add doc comments, update README.md * Refine FileDownloadDelegate description in README * Bump NIO version, remove weak self, cleanup test * Fix formatting issues in a doc comment * Create separate Progress struct, async open file * Create an ad-hoc EventLoopGroup for opening a file * Move file opening code to `didReceiveBodyPart` * Fix linter error in FileDownloadDelegate.swift * Fix wrong future assignment in FileDownloadDelegate * Fix Swift 5.0 return statement compatibility * Fix linter warning * Fix Swift 5.0 return statement compatibility * Remove redundant `write` function * Add negative test case and separate testing endpoint * Add missing testFileDownloadError to the manifest
1 parent 2a22156 commit 49a0d30

File tree

5 files changed

+255
-9
lines changed

5 files changed

+255
-9
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,35 @@ httpClient.execute(request: request, delegate: delegate).futureResult.whenSucces
165165
}
166166
```
167167

168+
### File downloads
169+
170+
Based on the `HTTPClientResponseDelegate` example above you can build more complex delegates,
171+
the built-in `FileDownloadDelegate` is one of them. It allows streaming the downloaded data
172+
asynchronously, while reporting the download progress at the same time, like in the following
173+
example:
174+
175+
```swift
176+
let client = HTTPClient(eventLoopGroupProvider: .createNew)
177+
let request = try HTTPClient.Request(
178+
url: "https://swift.org/builds/development/ubuntu1804/latest-build.yml"
179+
)
180+
181+
let delegate = try FileDownloadDelegate(path: "/tmp/latest-build.yml", reportProgress: {
182+
if let totalSize = $0 {
183+
print("Total bytes count: \(totalSize)")
184+
}
185+
print("Downloaded \($1) bytes so far")
186+
})
187+
188+
client.execute(request: request, delegate: delegate).futureResult
189+
.whenSuccess { finalTotalBytes, downloadedBytes in
190+
if let totalSize = $0 {
191+
print("Final total bytes count: \(totalSize)")
192+
}
193+
print("Downloaded finished with \($1) bytes downloaded")
194+
}
195+
```
196+
168197
### Unix Domain Socket Paths
169198
Connecting to servers bound to socket paths is easy:
170199
```swift
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2020 Apple Inc. and the AsyncHTTPClient project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import NIO
16+
import NIOHTTP1
17+
18+
/// Handles a streaming download to a given file path, allowing headers and progress to be reported.
19+
public final class FileDownloadDelegate: HTTPClientResponseDelegate {
20+
/// The response type for this delegate: the total count of bytes as reported by the response
21+
/// "Content-Length" header (if available) and the count of bytes downloaded.
22+
public struct Progress {
23+
public var totalBytes: Int?
24+
public var receivedBytes: Int
25+
}
26+
27+
private var progress = Progress(totalBytes: nil, receivedBytes: 0)
28+
29+
public typealias Response = Progress
30+
31+
private let filePath: String
32+
private let io: NonBlockingFileIO
33+
private let reportHead: ((HTTPResponseHead) -> Void)?
34+
private let reportProgress: ((Progress) -> Void)?
35+
36+
private var fileHandleFuture: EventLoopFuture<NIOFileHandle>?
37+
private var writeFuture: EventLoopFuture<Void>?
38+
39+
/// Initializes a new file download delegate.
40+
/// - parameters:
41+
/// - path: Path to a file you'd like to write the download to.
42+
/// - pool: A thread pool to use for asynchronous file I/O.
43+
/// - reportHead: A closure called when the response head is available.
44+
/// - reportProgress: A closure called when a body chunk has been downloaded, with
45+
/// the total byte count and download byte count passed to it as arguments. The callbacks
46+
/// will be invoked in the same threading context that the delegate itself is invoked,
47+
/// as controlled by `EventLoopPreference`.
48+
public init(
49+
path: String,
50+
pool: NIOThreadPool = NIOThreadPool(numberOfThreads: 1),
51+
reportHead: ((HTTPResponseHead) -> Void)? = nil,
52+
reportProgress: ((Progress) -> Void)? = nil
53+
) throws {
54+
pool.start()
55+
self.io = NonBlockingFileIO(threadPool: pool)
56+
self.filePath = path
57+
58+
self.reportHead = reportHead
59+
self.reportProgress = reportProgress
60+
}
61+
62+
public func didReceiveHead(
63+
task: HTTPClient.Task<Response>,
64+
_ head: HTTPResponseHead
65+
) -> EventLoopFuture<Void> {
66+
self.reportHead?(head)
67+
68+
if let totalBytesString = head.headers.first(name: "Content-Length"),
69+
let totalBytes = Int(totalBytesString) {
70+
self.progress.totalBytes = totalBytes
71+
}
72+
73+
return task.eventLoop.makeSucceededFuture(())
74+
}
75+
76+
public func didReceiveBodyPart(
77+
task: HTTPClient.Task<Response>,
78+
_ buffer: ByteBuffer
79+
) -> EventLoopFuture<Void> {
80+
self.progress.receivedBytes += buffer.readableBytes
81+
self.reportProgress?(self.progress)
82+
83+
let writeFuture: EventLoopFuture<Void>
84+
if let fileHandleFuture = self.fileHandleFuture {
85+
writeFuture = fileHandleFuture.flatMap {
86+
self.io.write(fileHandle: $0, buffer: buffer, eventLoop: task.eventLoop)
87+
}
88+
} else {
89+
let fileHandleFuture = self.io.openFile(
90+
path: self.filePath,
91+
mode: .write,
92+
flags: .allowFileCreation(),
93+
eventLoop: task.eventLoop
94+
)
95+
self.fileHandleFuture = fileHandleFuture
96+
writeFuture = fileHandleFuture.flatMap {
97+
self.io.write(fileHandle: $0, buffer: buffer, eventLoop: task.eventLoop)
98+
}
99+
}
100+
101+
self.writeFuture = writeFuture
102+
return writeFuture
103+
}
104+
105+
private func close(fileHandle: NIOFileHandle) {
106+
try! fileHandle.close()
107+
self.fileHandleFuture = nil
108+
}
109+
110+
private func finalize() {
111+
if let writeFuture = self.writeFuture {
112+
writeFuture.whenComplete { _ in
113+
self.fileHandleFuture?.whenSuccess(self.close(fileHandle:))
114+
self.writeFuture = nil
115+
}
116+
} else {
117+
self.fileHandleFuture?.whenSuccess(self.close(fileHandle:))
118+
}
119+
}
120+
121+
public func didReceiveError(task: HTTPClient.Task<Progress>, _ error: Error) {
122+
self.finalize()
123+
}
124+
125+
public func didFinishRequest(task: HTTPClient.Task<Response>) throws -> Response {
126+
self.finalize()
127+
return self.progress
128+
}
129+
}

Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,31 @@ enum TemporaryFileHelpers {
182182
}
183183
return try body(shortEnoughPath)
184184
}
185+
186+
/// This function creates a filename that can be used as a temporary file.
187+
internal static func withTemporaryFilePath<T>(
188+
directory: String = temporaryDirectory,
189+
_ body: (String) throws -> T
190+
) throws -> T {
191+
let (fd, path) = self.openTemporaryFile()
192+
close(fd)
193+
try! FileManager.default.removeItem(atPath: path)
194+
195+
defer {
196+
if FileManager.default.fileExists(atPath: path) {
197+
try? FileManager.default.removeItem(atPath: path)
198+
}
199+
}
200+
return try body(path)
201+
}
202+
203+
internal static func fileSize(path: String) throws -> Int? {
204+
return try FileManager.default.attributesOfItem(atPath: path)[.size] as? Int
205+
}
206+
207+
internal static func fileExists(path: String) -> Bool {
208+
return FileManager.default.fileExists(atPath: path)
209+
}
185210
}
186211

187212
internal final class HTTPBin {
@@ -420,6 +445,25 @@ internal final class HttpBinHandler: ChannelInboundHandler {
420445
}
421446
}
422447

448+
func writeEvents(context: ChannelHandlerContext, isContentLengthRequired: Bool = false) {
449+
let headers: HTTPHeaders
450+
if isContentLengthRequired {
451+
headers = HTTPHeaders([("Content-Length", "50")])
452+
} else {
453+
headers = HTTPHeaders()
454+
}
455+
456+
context.write(wrapOutboundOut(.head(HTTPResponseHead(version: HTTPVersion(major: 1, minor: 1), status: .ok, headers: headers))), promise: nil)
457+
for i in 0..<10 {
458+
let msg = "id: \(i)"
459+
var buf = context.channel.allocator.buffer(capacity: msg.count)
460+
buf.writeString(msg)
461+
context.writeAndFlush(wrapOutboundOut(.body(.byteBuffer(buf))), promise: nil)
462+
Thread.sleep(forTimeInterval: 0.05)
463+
}
464+
context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
465+
}
466+
423467
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
424468
self.isServingRequest = true
425469
switch self.unwrapInboundIn(data) {
@@ -531,16 +575,10 @@ internal final class HttpBinHandler: ChannelInboundHandler {
531575
context.writeAndFlush(wrapOutboundOut(.head(HTTPResponseHead(version: HTTPVersion(major: 1, minor: 1), status: .ok))), promise: nil)
532576
return
533577
case "/events/10/1": // TODO: parse path
534-
context.write(wrapOutboundOut(.head(HTTPResponseHead(version: HTTPVersion(major: 1, minor: 1), status: .ok))), promise: nil)
535-
for i in 0..<10 {
536-
let msg = "id: \(i)"
537-
var buf = context.channel.allocator.buffer(capacity: msg.count)
538-
buf.writeString(msg)
539-
context.writeAndFlush(wrapOutboundOut(.body(.byteBuffer(buf))), promise: nil)
540-
Thread.sleep(forTimeInterval: 0.05)
541-
}
542-
context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
578+
self.writeEvents(context: context)
543579
return
580+
case "/events/10/content-length":
581+
self.writeEvents(context: context, isContentLengthRequired: true)
544582
default:
545583
context.write(wrapOutboundOut(.head(HTTPResponseHead(version: HTTPVersion(major: 1, minor: 1), status: .notFound))), promise: nil)
546584
context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)

Tests/AsyncHTTPClientTests/HTTPClientTests+XCTest.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ extension HTTPClientTests {
4444
("testPercentEncodedBackslash", testPercentEncodedBackslash),
4545
("testMultipleContentLengthHeaders", testMultipleContentLengthHeaders),
4646
("testStreaming", testStreaming),
47+
("testFileDownload", testFileDownload),
48+
("testFileDownloadError", testFileDownloadError),
4749
("testRemoteClose", testRemoteClose),
4850
("testReadTimeout", testReadTimeout),
4951
("testConnectTimeout", testConnectTimeout),

Tests/AsyncHTTPClientTests/HTTPClientTests.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,54 @@ class HTTPClientTests: XCTestCase {
488488
XCTAssertEqual(10, count)
489489
}
490490

491+
func testFileDownload() throws {
492+
var request = try Request(url: self.defaultHTTPBinURLPrefix + "events/10/content-length")
493+
request.headers.add(name: "Accept", value: "text/event-stream")
494+
495+
let progress =
496+
try TemporaryFileHelpers.withTemporaryFilePath { path -> FileDownloadDelegate.Progress in
497+
let delegate = try FileDownloadDelegate(path: path)
498+
499+
let progress = try self.defaultClient.execute(
500+
request: request,
501+
delegate: delegate
502+
)
503+
.wait()
504+
505+
try XCTAssertEqual(50, TemporaryFileHelpers.fileSize(path: path))
506+
507+
return progress
508+
}
509+
510+
XCTAssertEqual(50, progress.totalBytes)
511+
XCTAssertEqual(50, progress.receivedBytes)
512+
}
513+
514+
func testFileDownloadError() throws {
515+
var request = try Request(url: self.defaultHTTPBinURLPrefix + "not-found")
516+
request.headers.add(name: "Accept", value: "text/event-stream")
517+
518+
let progress =
519+
try TemporaryFileHelpers.withTemporaryFilePath { path -> FileDownloadDelegate.Progress in
520+
let delegate = try FileDownloadDelegate(path: path, reportHead: {
521+
XCTAssertEqual($0.status, .notFound)
522+
})
523+
524+
let progress = try self.defaultClient.execute(
525+
request: request,
526+
delegate: delegate
527+
)
528+
.wait()
529+
530+
XCTAssertFalse(TemporaryFileHelpers.fileExists(path: path))
531+
532+
return progress
533+
}
534+
535+
XCTAssertEqual(nil, progress.totalBytes)
536+
XCTAssertEqual(0, progress.receivedBytes)
537+
}
538+
491539
func testRemoteClose() throws {
492540
XCTAssertThrowsError(try self.defaultClient.get(url: self.defaultHTTPBinURLPrefix + "close").wait(), "Should fail") { error in
493541
guard case let error = error as? HTTPClientError, error == .remoteConnectionClosed else {

0 commit comments

Comments
 (0)