diff --git a/Sources/HTTPClient/Base/PrettyStringConvertible.swift b/Sources/HTTPClient/Base/PrettyStringConvertible.swift index cfcb75c..bc99c16 100644 --- a/Sources/HTTPClient/Base/PrettyStringConvertible.swift +++ b/Sources/HTTPClient/Base/PrettyStringConvertible.swift @@ -15,6 +15,6 @@ /// A helper protocol for customizing descriptions. internal protocol PrettyStringConvertible { - /// A pretty string description. - var prettyDescription: String { get } -} \ No newline at end of file + /// A pretty string description. + var prettyDescription: String { get } +} diff --git a/Sources/HTTPClient/Interface/HTTPBody+Extensions.swift b/Sources/HTTPClient/Interface/HTTPBody+Extensions.swift index 4c54c51..b1236f3 100644 --- a/Sources/HTTPClient/Interface/HTTPBody+Extensions.swift +++ b/Sources/HTTPClient/Interface/HTTPBody+Extensions.swift @@ -1,32 +1,32 @@ import Foundation extension HTTPBody { - /// Decodes the HTTP body as the specified type using the provided decoder. - /// - Parameters: - /// - type: The type to decode the HTTP body as. - /// - decoder: The decoder to use for decoding the HTTP body. - /// - Returns: The decoded value. - /// - Throws: An error if the HTTP body cannot be decoded. - public func decode( - as type: T.Type, - using decoder: JSONDecoder = JSONDecoder() - ) async throws -> T { - let data = try await Data(collecting: self, upTo: .max) - return try decoder.decode(type, from: data) - } + /// Decodes the HTTP body as the specified type using the provided decoder. + /// - Parameters: + /// - type: The type to decode the HTTP body as. + /// - decoder: The decoder to use for decoding the HTTP body. + /// - Returns: The decoded value. + /// - Throws: An error if the HTTP body cannot be decoded. + public func decode( + as type: T.Type, + using decoder: JSONDecoder = JSONDecoder() + ) async throws -> T { + let data = try await Data(collecting: self, upTo: .max) + return try decoder.decode(type, from: data) + } - /// Decodes the HTTP body as a JSON object. - /// - Returns: The decoded JSON object. - /// - Throws: An error if the HTTP body cannot be decoded as JSON. - public func json() async throws -> Any { - let data = try await Data(collecting: self, upTo: .max) - return try JSONSerialization.jsonObject(with: data) - } + /// Decodes the HTTP body as a JSON object. + /// - Returns: The decoded JSON object. + /// - Throws: An error if the HTTP body cannot be decoded as JSON. + public func json() async throws -> Any { + let data = try await Data(collecting: self, upTo: .max) + return try JSONSerialization.jsonObject(with: data) + } - /// Decodes the HTTP body as a string. - /// - Returns: The decoded string. - /// - Throws: An error if the HTTP body cannot be decoded as a string. - public func string() async throws -> String { - try await String(collecting: self, upTo: .max) - } + /// Decodes the HTTP body as a string. + /// - Returns: The decoded string. + /// - Throws: An error if the HTTP body cannot be decoded as a string. + public func string() async throws -> String { + try await String(collecting: self, upTo: .max) + } } diff --git a/Sources/HTTPClient/Interface/HTTPBody+Progress.swift b/Sources/HTTPClient/Interface/HTTPBody+Progress.swift new file mode 100644 index 0000000..6f7cd6d --- /dev/null +++ b/Sources/HTTPClient/Interface/HTTPBody+Progress.swift @@ -0,0 +1,40 @@ +import Foundation + +extension HTTPBody { + /// Tracks the progress of the body and calls the handler. + public func trackingProgress(handler: @escaping (Progress) -> Void) -> HTTPBody { + var totalBytesProcessed: Int64 = 0 + let totalLength: Int64? = + switch self.length { + case .known(let length): + length + case .unknown: + nil + } + + let sequence = self.map { chunk in + let chunkSize = Int64(chunk.count) + totalBytesProcessed += chunkSize + + let progress = Progress(completed: totalBytesProcessed, total: totalLength) + handler(progress) + return chunk + } + + return HTTPBody(sequence, length: self.length, iterationBehavior: self.iterationBehavior) + } +} + +/// A progress object that tracks the progress of a body. +public struct Progress: Sendable, Hashable { + /// The number of bytes processed. + public let completed: Int64 + /// The total number of bytes to process. + public let total: Int64? + + /// The fraction of the body that has been processed. + public var fractionCompleted: Double? { + guard let total = total, total > 0 else { return nil } + return Double(completed) / Double(total) + } +} diff --git a/Tests/HTTPClientTests/HTTPBodyProgressTests.swift b/Tests/HTTPClientTests/HTTPBodyProgressTests.swift new file mode 100644 index 0000000..8541e00 --- /dev/null +++ b/Tests/HTTPClientTests/HTTPBodyProgressTests.swift @@ -0,0 +1,348 @@ +import Foundation +import Testing + +@testable import HTTPClient + +@Suite +struct HTTPBodyProgressTests { + + // Disambiguate from Foundation.Progress + typealias Progress = HTTPClient.Progress + + // MARK: - Progress struct tests + + @Test func progressInitialization() { + let progress = Progress(completed: 100, total: 200) + #expect(progress.completed == 100) + #expect(progress.total == 200) + } + + @Test func progressWithUnknownTotal() { + let progress = Progress(completed: 50, total: nil) + #expect(progress.completed == 50) + #expect(progress.total == nil) + } + + @Test func progressFractionCompletedKnownTotal() { + let progress = Progress(completed: 50, total: 100) + #expect(progress.fractionCompleted == 0.5) + } + + @Test func progressFractionCompletedFullyComplete() { + let progress = Progress(completed: 100, total: 100) + #expect(progress.fractionCompleted == 1.0) + } + + @Test func progressFractionCompletedZeroCompleted() { + let progress = Progress(completed: 0, total: 100) + #expect(progress.fractionCompleted == 0.0) + } + + @Test func progressFractionCompletedUnknownTotal() { + let progress = Progress(completed: 50, total: nil) + #expect(progress.fractionCompleted == nil) + } + + @Test func progressFractionCompletedZeroTotal() { + let progress = Progress(completed: 0, total: 0) + #expect(progress.fractionCompleted == nil) + } + + @Test func progressEquality() { + let progress1 = Progress(completed: 50, total: 100) + let progress2 = Progress(completed: 50, total: 100) + let progress3 = Progress(completed: 51, total: 100) + + #expect(progress1 == progress2) + #expect(progress1 != progress3) + } + + @Test func progressHashable() { + let progress1 = Progress(completed: 50, total: 100) + let progress2 = Progress(completed: 50, total: 100) + + var set = Set() + set.insert(progress1) + set.insert(progress2) + + // Same progress values should result in one element + #expect(set.count == 1) + } + + // MARK: - trackingProgress tests + + @Test func trackingProgressWithSingleChunk() async throws { + let data = Data("Hello, World!".utf8) + let body = HTTPBody(data) + + var progressUpdates: [Progress] = [] + let trackedBody = body.trackingProgress { progress in + progressUpdates.append(progress) + } + + // Consume the body + for try await _ in trackedBody {} + + #expect(progressUpdates.count == 1) + #expect(progressUpdates[0].completed == 13) + #expect(progressUpdates[0].total == 13) + #expect(progressUpdates[0].fractionCompleted == 1.0) + } + + @Test func trackingProgressWithMultipleChunks() async throws { + let chunks: [HTTPBody.ByteChunk] = [ + Array("Hello".utf8)[...], + Array(", ".utf8)[...], + Array("World!".utf8)[...], + ] + let body = HTTPBody(chunks, length: .known(13), iterationBehavior: .multiple) + + var progressUpdates: [Progress] = [] + let trackedBody = body.trackingProgress { progress in + progressUpdates.append(progress) + } + + // Consume the body + for try await _ in trackedBody {} + + #expect(progressUpdates.count == 3) + + // First chunk: "Hello" (5 bytes) + #expect(progressUpdates[0].completed == 5) + #expect(progressUpdates[0].total == 13) + #expect(progressUpdates[0].fractionCompleted == 5.0 / 13.0) + + // Second chunk: ", " (2 bytes, cumulative 7) + #expect(progressUpdates[1].completed == 7) + #expect(progressUpdates[1].total == 13) + #expect(progressUpdates[1].fractionCompleted == 7.0 / 13.0) + + // Third chunk: "World!" (6 bytes, cumulative 13) + #expect(progressUpdates[2].completed == 13) + #expect(progressUpdates[2].total == 13) + #expect(progressUpdates[2].fractionCompleted == 1.0) + } + + @Test func trackingProgressWithUnknownLength() async throws { + let chunks: [HTTPBody.ByteChunk] = [ + Array("Part1".utf8)[...], + Array("Part2".utf8)[...], + ] + let body = HTTPBody(chunks, length: .unknown, iterationBehavior: .multiple) + + var progressUpdates: [Progress] = [] + let trackedBody = body.trackingProgress { progress in + progressUpdates.append(progress) + } + + // Consume the body + for try await _ in trackedBody {} + + #expect(progressUpdates.count == 2) + + // First chunk + #expect(progressUpdates[0].completed == 5) + #expect(progressUpdates[0].total == nil) + #expect(progressUpdates[0].fractionCompleted == nil) + + // Second chunk + #expect(progressUpdates[1].completed == 10) + #expect(progressUpdates[1].total == nil) + #expect(progressUpdates[1].fractionCompleted == nil) + } + + @Test func trackingProgressWithEmptyBody() async throws { + let body = HTTPBody() + + var progressUpdates: [Progress] = [] + let trackedBody = body.trackingProgress { progress in + progressUpdates.append(progress) + } + + // Consume the body + for try await _ in trackedBody {} + + // No chunks, so no progress updates + #expect(progressUpdates.isEmpty) + } + + @Test func trackingProgressPreservesBodyLength() { + let body = HTTPBody("Test", length: .known(4)) + let trackedBody = body.trackingProgress { _ in } + + #expect(trackedBody.length == .known(4)) + } + + @Test func trackingProgressPreservesUnknownLength() { + let chunks: [HTTPBody.ByteChunk] = [Array("Test".utf8)[...]] + let body = HTTPBody(chunks, length: .unknown, iterationBehavior: .multiple) + let trackedBody = body.trackingProgress { _ in } + + #expect(trackedBody.length == .unknown) + } + + @Test func trackingProgressPreservesIterationBehavior() { + let body = HTTPBody("Test", length: .known(4)) + let trackedBody = body.trackingProgress { _ in } + + #expect(trackedBody.iterationBehavior == body.iterationBehavior) + } + + @Test func trackingProgressWithLargeData() async throws { + // Create a larger body with multiple chunks + let chunkSize = 1024 + let numChunks = 10 + var chunks: [HTTPBody.ByteChunk] = [] + + for _ in 0.. { continuation in + for chunk in chunks { + continuation.yield(Array(chunk.utf8)[...]) + } + continuation.finish() + } + + let totalLength = chunks.joined().count + let body = HTTPBody(stream, length: .known(Int64(totalLength))) + + var progressUpdates: [Progress] = [] + let trackedBody = body.trackingProgress { progress in + progressUpdates.append(progress) + } + + // Consume the body + for try await _ in trackedBody {} + + #expect(progressUpdates.count == 3) + #expect(progressUpdates.last?.completed == Int64(totalLength)) + } + + @Test func trackingProgressHandlerCalledInOrder() async throws { + let chunks: [HTTPBody.ByteChunk] = [ + Array("1".utf8)[...], + Array("2".utf8)[...], + Array("3".utf8)[...], + ] + let body = HTTPBody(chunks, length: .known(3), iterationBehavior: .multiple) + + var completedValues: [Int64] = [] + let trackedBody = body.trackingProgress { progress in + completedValues.append(progress.completed) + } + + // Consume the body + for try await _ in trackedBody {} + + // Verify progress is monotonically increasing + #expect(completedValues == [1, 2, 3]) + } + + @Test func trackingProgressDoesNotModifyChunks() async throws { + let originalData = "Test Data" + let body = HTTPBody(originalData) + + let trackedBody = body.trackingProgress { _ in } + + // Consume and collect the data + let collectedData = try await String(collecting: trackedBody, upTo: 1024) + + #expect(collectedData == originalData) + } + + @Test func trackingProgressMultipleIterations() async throws { + let chunks: [HTTPBody.ByteChunk] = [ + Array("AB".utf8)[...], + Array("CD".utf8)[...], + ] + let body = HTTPBody(chunks, length: .known(4), iterationBehavior: .multiple) + + var firstIterationUpdates: [Progress] = [] + let trackedBody = body.trackingProgress { progress in + firstIterationUpdates.append(progress) + } + + // First iteration + for try await _ in trackedBody {} + + #expect(firstIterationUpdates.count == 2) + + // Note: Second iteration would create a new tracked body + // as trackingProgress returns a new HTTPBody instance + var secondIterationUpdates: [Progress] = [] + let trackedBody2 = body.trackingProgress { progress in + secondIterationUpdates.append(progress) + } + + // Second iteration + for try await _ in trackedBody2 {} + + #expect(secondIterationUpdates.count == 2) + } + + @Test func progressFractionCompletedEdgeCases() { + // Test with very large numbers + let largeProgress = Progress(completed: Int64.max - 1, total: Int64.max) + #expect(largeProgress.fractionCompleted != nil) + + // Test with small total + let smallProgress = Progress(completed: 1, total: 2) + #expect(smallProgress.fractionCompleted == 0.5) + } + + @Test func trackingProgressWithZeroSizedChunks() async throws { + let chunks: [HTTPBody.ByteChunk] = [ + Array("".utf8)[...], + Array("Hello".utf8)[...], + Array("".utf8)[...], + ] + let body = HTTPBody(chunks, length: .known(5), iterationBehavior: .multiple) + + var progressUpdates: [Progress] = [] + let trackedBody = body.trackingProgress { progress in + progressUpdates.append(progress) + } + + // Consume the body + for try await _ in trackedBody {} + + // Should have 3 updates (one for each chunk, even zero-sized ones) + #expect(progressUpdates.count == 3) + #expect(progressUpdates[0].completed == 0) // Empty chunk + #expect(progressUpdates[1].completed == 5) // "Hello" + #expect(progressUpdates[2].completed == 5) // Empty chunk (no change) + } +}