Skip to content

Commit 4f34966

Browse files
authored
Merge pull request #157 from swhitty/filehandler-partial-range-requests
Update FileHTTPHandler to support partial range requests
2 parents e4e48d7 + 14ba490 commit 4f34966

File tree

7 files changed

+203
-26
lines changed

7 files changed

+203
-26
lines changed

FlyingFox/Sources/HTTPBodySequence.swift

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,12 @@ public struct HTTPBodySequence: Sendable, AsyncSequence {
6969
)
7070
}
7171

72-
public init(file url: URL, suggestedBufferSize: Int = 4096) throws {
73-
self.storage = try Storage(fileURL: url, bufferSize: suggestedBufferSize)
72+
public init(file url: URL, range: Range<Int>? = nil, suggestedBufferSize: Int = 4096) throws {
73+
self.storage = try Storage(
74+
fileURL: url,
75+
range: range,
76+
bufferSize: suggestedBufferSize
77+
)
7478
}
7579

7680
public func makeAsyncIterator() -> Iterator {
@@ -91,9 +95,10 @@ public struct HTTPBodySequence: Sendable, AsyncSequence {
9195
self.canReplay = true
9296
}
9397

94-
init(fileURL: URL, bufferSize: Int) throws {
95-
self.sequence = AsyncBufferedFileSequence(contentsOf: fileURL)
96-
self.count = try AsyncBufferedFileSequence.fileSize(at: fileURL)
98+
init(fileURL: URL, range: Range<Int>?, bufferSize: Int) throws {
99+
let fileSequence = try AsyncBufferedFileSequence(contentsOf: fileURL, range: range)
100+
self.sequence = fileSequence
101+
self.count = fileSequence.count
97102
self.bufferSize = bufferSize
98103
self.canReplay = true
99104
}

FlyingFox/Sources/HTTPEncoder.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ struct HTTPEncoder {
3939
response.statusCode.phrase].joined(separator: " ")
4040

4141
var httpHeaders = response.headers
42-
if let contentLength = makeContentLength(from: response.payload) {
42+
if let contentLength = makeContentLength(from: response.payload),
43+
httpHeaders[.contentLength] == nil {
4344
httpHeaders[.contentLength] = String(contentLength)
4445
} else if let encoding = makeTransferEncoding(from: response.payload) {
4546
httpHeaders.addValue(encoding, for: .transferEncoding)

FlyingFox/Sources/Handlers/FileHTTPHandler.swift

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
// SOFTWARE.
3030
//
3131

32+
import FlyingSocks
3233
import Foundation
3334

3435
public struct FileHTTPHandler: HTTPHandler {
@@ -62,10 +63,14 @@ public struct FileHTTPHandler: HTTPHandler {
6263
return "image/png"
6364
case "jpeg", "jpg":
6465
return "image/jpeg"
66+
case "m4v", "mp4":
67+
return "video/mp4"
6568
case "pdf":
6669
return "application/pdf"
6770
case "svg":
6871
return "image/svg+xml"
72+
case "txt":
73+
return "text/plain"
6974
case "ico":
7075
return "image/x-icon"
7176
case "wasm":
@@ -85,13 +90,51 @@ public struct FileHTTPHandler: HTTPHandler {
8590
}
8691

8792
do {
88-
return try HTTPResponse(
89-
statusCode: .ok,
90-
headers: [.contentType: contentType],
91-
body: HTTPBodySequence(file: path)
92-
)
93+
var headers: [HTTPHeader: String] = [
94+
.contentType: contentType,
95+
.acceptRanges: "bytes"
96+
]
97+
98+
let fileSize = try AsyncBufferedFileSequence.fileSize(at: path)
99+
100+
if request.method == .HEAD {
101+
headers[.contentLength] = String(fileSize)
102+
return HTTPResponse(
103+
statusCode: .ok,
104+
headers: headers
105+
)
106+
}
107+
108+
if let range = Self.makePartialRange(for: request.headers) {
109+
headers[.contentRange] = "bytes \(range.lowerBound)-\(range.upperBound)/\(fileSize)"
110+
return try HTTPResponse(
111+
statusCode: .partialContent,
112+
headers: headers,
113+
body: HTTPBodySequence(file: path, range: range.lowerBound..<range.upperBound + 1)
114+
)
115+
} else {
116+
return try HTTPResponse(
117+
statusCode: .ok,
118+
headers: headers,
119+
body: HTTPBodySequence(file: path)
120+
)
121+
}
93122
} catch {
94123
return HTTPResponse(statusCode: .notFound)
95124
}
96125
}
126+
127+
static func makePartialRange(for headers: [HTTPHeader: String]) -> ClosedRange<Int>? {
128+
guard let headerValue = headers[.range] else { return nil }
129+
let scanner = Scanner(string: headerValue)
130+
guard scanner.scanString("bytes") != nil,
131+
scanner.scanString("=") != nil,
132+
let start = scanner.scanInt(),
133+
scanner.scanString("-") != nil,
134+
let end = scanner.scanInt(),
135+
start <= end else {
136+
return nil
137+
}
138+
return start...end
139+
}
97140
}

FlyingFox/Tests/Handlers/HTTPHandlerTests.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,15 @@ struct HTTPHandlerTests {
155155
#expect(
156156
FileHTTPHandler.makeContentType(for: "fish.svg") == "image/svg+xml"
157157
)
158+
#expect(
159+
FileHTTPHandler.makeContentType(for: "fish.txt") == "text/plain"
160+
)
161+
#expect(
162+
FileHTTPHandler.makeContentType(for: "fish.mp4") == "video/mp4"
163+
)
164+
#expect(
165+
FileHTTPHandler.makeContentType(for: "fish.m4v") == "video/mp4"
166+
)
158167
#expect(
159168
FileHTTPHandler.makeContentType(for: "fish.ico") == "image/x-icon"
160169
)
@@ -172,6 +181,46 @@ struct HTTPHandlerTests {
172181
)
173182
}
174183

184+
@Test
185+
func fileHandler_DetectsPartialRange() {
186+
#expect(
187+
FileHTTPHandler.makePartialRange(for: [.range: "bytes=1-5"]) == 1...5
188+
)
189+
#expect(
190+
FileHTTPHandler.makePartialRange(for: [.range: "bytes = 8 - 10"]) == 8...10
191+
)
192+
#expect(
193+
FileHTTPHandler.makePartialRange(for: [.range: "bytes=5-1"]) == nil
194+
)
195+
#expect(
196+
FileHTTPHandler.makePartialRange(for: [.range: "byte=1-5"]) == nil
197+
)
198+
#expect(
199+
FileHTTPHandler.makePartialRange(for: [.range: ""]) == nil
200+
)
201+
}
202+
203+
@Test
204+
func fileHandler_ReturnsHeaders_WhenMethodIsHEAD() async throws {
205+
let handler = FileHTTPHandler(named: "Stubs/fish.json", in: .module)
206+
207+
let response = try await handler.handleRequest(.make(method: .HEAD))
208+
#expect(response.statusCode == .ok)
209+
#expect(response.headers[.contentLength] == "17")
210+
#expect(response.headers[.acceptRanges] == "bytes")
211+
try await #expect(response.bodyData.isEmpty)
212+
}
213+
214+
@Test
215+
func fileHandler_Returns206WhenPartialRangeRequested() async throws {
216+
let handler = FileHTTPHandler(named: "Stubs/fish.json", in: .module)
217+
218+
let response = try await handler.handleRequest(.make(headers: [.range: "bytes=10-14"]))
219+
#expect(response.statusCode == .partialContent)
220+
#expect(response.headers[.contentRange] == "bytes 10-14/17")
221+
try await #expect(response.bodyString == "cakes")
222+
}
223+
175224
//MARK: - ProxyHTTPHandler
176225

177226
@Test(.disabled("pie.dev appears to be down"))

FlyingSocks/Sources/AsyncBufferedFileSequence.swift

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,27 +35,46 @@ package struct AsyncBufferedFileSequence: AsyncBufferedSequence {
3535
package typealias Element = UInt8
3636

3737
private let fileURL: URL
38+
private let range: Range<Int>
3839

39-
package init(contentsOf fileURL: URL) {
40+
package let fileSize: Int
41+
package var count: Int { range.count }
42+
43+
package init(contentsOf fileURL: URL, range: Range<Int>? = nil) throws {
4044
self.fileURL = fileURL
45+
self.fileSize = try Self.fileSize(at: fileURL)
46+
47+
if let range {
48+
self.range = range
49+
guard range.lowerBound >= 0, range.upperBound <= fileSize else {
50+
throw FileSizeError("Invalid range \(range) for file size \(fileSize)")
51+
}
52+
} else {
53+
self.range = 0..<fileSize
54+
}
4155
}
4256

4357
package func makeAsyncIterator() -> Iterator {
44-
Iterator(fileURL: fileURL)
58+
Iterator(fileURL: fileURL, range: range)
4559
}
4660

4761
package struct Iterator: AsyncBufferedIteratorProtocol {
4862

4963
private let fileURL: URL
64+
private let range: Range<Int>
5065
private var fileHandle: FileHandle?
66+
private var offset: Int = 0
5167

52-
init(fileURL: URL) {
68+
init(fileURL: URL, range: Range<Int>) {
5369
self.fileURL = fileURL
70+
self.range = range
71+
self.offset = range.lowerBound
5472
}
5573

5674
private mutating func makeOrGetFileHandle() throws -> FileHandle {
5775
guard let fileHandle else {
5876
let handle = try FileHandle(forReadingFrom: fileURL)
77+
try handle.seek(toOffset: UInt64(offset))
5978
self.fileHandle = handle
6079
return handle
6180
}
@@ -67,7 +86,16 @@ package struct AsyncBufferedFileSequence: AsyncBufferedSequence {
6786
}
6887

6988
package mutating func nextBuffer(suggested count: Int) async throws -> Data? {
70-
try makeOrGetFileHandle().read(suggestedCount: count)
89+
let endIndex = Swift.min(offset + count, range.upperBound)
90+
guard endIndex <= range.upperBound else {
91+
return nil
92+
}
93+
guard let data = try makeOrGetFileHandle().read(suggestedCount: endIndex - offset) else {
94+
return nil
95+
}
96+
97+
offset += data.count
98+
return data
7199
}
72100
}
73101
}
@@ -85,20 +113,24 @@ extension FileHandle {
85113
}
86114
}
87115

88-
package extension AsyncBufferedFileSequence {
116+
extension AsyncBufferedFileSequence {
89117

90-
static func fileSize(at url: URL) throws -> Int {
118+
package static func fileSize(at url: URL) throws -> Int {
91119
try fileSize(from: FileManager.default.attributesOfItem(atPath: url.path))
92120
}
93121

94-
internal static func fileSize(from att: [FileAttributeKey: Any]) throws -> Int {
122+
static func fileSize(from att: [FileAttributeKey: Any]) throws -> Int {
95123
guard let size = att[.size] as? UInt64 else {
96-
throw FileSizeError()
124+
throw FileSizeError("File size not found")
97125
}
98126
return Int(size)
99127
}
100128

101-
internal struct FileSizeError: LocalizedError {
102-
package var errorDescription: String? = "File size not found"
129+
struct FileSizeError: LocalizedError {
130+
package var errorDescription: String?
131+
132+
init(_ message: String) {
133+
self.errorDescription = message
134+
}
103135
}
104136
}

FlyingSocks/Tests/AsyncBufferedFileSequenceTests.swift

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ struct AsyncBufferedFileSequenceTests {
3939
func fileSize() async throws {
4040
#if os(Windows)
4141
try #expect(
42-
AsyncBufferedFileSequence.fileSize(at: .jackOfHeartsRecital) == 304
42+
AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital).fileSize == 304
4343
)
4444
#else
4545
try #expect(
46-
AsyncBufferedFileSequence.fileSize(at: .jackOfHeartsRecital) == 299
46+
AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital).fileSize == 299
4747
)
4848
#endif
4949

@@ -53,6 +53,28 @@ struct AsyncBufferedFileSequenceTests {
5353
#expect(throws: (any Error).self) {
5454
try AsyncBufferedFileSequence.fileSize(from: [:])
5555
}
56+
#expect(throws: (any Error).self) {
57+
try AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital, range: 0..<1000)
58+
}
59+
}
60+
61+
@Test
62+
func count() async throws {
63+
#if os(Windows)
64+
try #expect(
65+
AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital).count == 304
66+
)
67+
#else
68+
try #expect(
69+
AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital).count == 299
70+
)
71+
#endif
72+
try #expect(
73+
AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital, range: 0..<10).count == 10
74+
)
75+
try #expect(
76+
AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital, range: 20..<25).count == 5
77+
)
5678
}
5779

5880
@Test
@@ -68,12 +90,25 @@ struct AsyncBufferedFileSequenceTests {
6890

6991
@Test
7092
func readsEntireFile() async throws {
71-
let sequence = AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital)
93+
let sequence = try AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital)
7294

7395
#expect(
7496
try await sequence.getAllData() == Data(contentsOf: .jackOfHeartsRecital)
7597
)
7698
}
99+
100+
@Test
101+
func readsPartialFile() async throws {
102+
let sequence = try AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital, range: 4..<9)
103+
#expect(
104+
try await sequence.readAllToString() == "doors"
105+
)
106+
107+
let another = try AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital, range: 15..<31)
108+
#expect(
109+
try await another.readAllToString() == "the boys finally"
110+
)
111+
}
77112
}
78113

79114
private extension URL {
@@ -82,3 +117,15 @@ private extension URL {
82117
.appendingPathComponent("JackOfHeartsRecital.txt")
83118
}
84119
}
120+
121+
private extension AsyncBufferedSequence where Element == UInt8 {
122+
123+
func readAllToString(suggestedBuffer count: Int = 4096) async throws -> String {
124+
let data = try await getAllData()
125+
guard let string = String(data: data, encoding: .utf8) else {
126+
throw SocketError.disconnected
127+
}
128+
return string
129+
}
130+
}
131+

FlyingSocks/XCTests/AsyncBufferedFileSequenceTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ final class AsyncBufferedFileSequenceTests: XCTestCase {
6161
)
6262
}
6363

64-
func testReadsEntireFile() async {
65-
let sequence = AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital)
64+
func testReadsEntireFile() async throws {
65+
let sequence = try AsyncBufferedFileSequence(contentsOf: .jackOfHeartsRecital)
6666

6767
await AsyncAssertEqual(
6868
try await sequence.getAllData(),

0 commit comments

Comments
 (0)