Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 6 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ The library is structured as a single module with organized subdirectories:

## Platform Support

- iOS 13.0+
- macOS 10.15+
- macCatalyst 13.0+
- watchOS 6.0+
- tvOS 13.0+
- Swift 5.10+
- iOS 16.0+
- macOS 13.0+
- macCatalyst 16.0+
- watchOS 9.0+
- tvOS 16.0+
- Swift 6.0+

## Development Commands

Expand Down
12 changes: 6 additions & 6 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
// swift-tools-version: 5.10
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "swift-http-client",
platforms: [
.iOS(.v13),
.macCatalyst(.v13),
.macOS(.v10_15),
.watchOS(.v6),
.tvOS(.v13),
.iOS(.v16),
.macCatalyst(.v16),
.macOS(.v13),
.watchOS(.v9),
.tvOS(.v16),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ extension Client {
///
/// - SeeAlso: ``MultipartFormData``
public func send(
multipartFormData: MultipartFormData,
multipartFormData: sending MultipartFormData,
with request: HTTPRequest,
usingThreshold encodingMemoryThreshold: UInt64 = MultipartFormData.encodingMemoryThreshold
) async throws -> (HTTPResponse, HTTPBody?) {
Expand Down
6 changes: 3 additions & 3 deletions Sources/HTTPClient/Interface/ClientTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public protocol ClientTransport: Sendable {
/// - Throws: An error if sending the request and receiving the response fails.
func send(
_ request: HTTPRequest,
body: HTTPBody?,
body: sending HTTPBody?,
baseURL: URL
) async throws -> (HTTPResponse, HTTPBody?)
}
Expand Down Expand Up @@ -207,8 +207,8 @@ public protocol ClientMiddleware: Sendable {
/// - Throws: An error if interception of the request and response fails.
func intercept(
_ request: HTTPRequest,
body: HTTPBody?,
body: sending HTTPBody?,
baseURL: URL,
next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
next: @Sendable (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
) async throws -> (HTTPResponse, HTTPBody?)
}
40 changes: 16 additions & 24 deletions Sources/HTTPClient/Interface/MultipartFormData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import HTTPTypes
/// - https://www.w3.org/TR/html401/interact/forms.html#h-17.13
///
/// > Note: This implementation is adapted from Alamofire's MultipartFormData implementation.
public final class MultipartFormData: Sendable {
public final class MultipartFormData {
// MARK: - Helper Types

enum EncodingCharacters {
Expand Down Expand Up @@ -65,7 +65,7 @@ public final class MultipartFormData: Sendable {
}
}

final class BodyPart: @unchecked Sendable {
final class BodyPart {
let headers: HTTPFields
let bodyStream: InputStream
let bodyContentLength: UInt64
Expand All @@ -85,7 +85,7 @@ public final class MultipartFormData: Sendable {
public let reason: Reason

/// The detailed reason for the encoding failure.
public enum Reason {
public enum Reason: Sendable {
case bodyPartURLInvalid(url: URL)
case bodyPartFilenameInvalid(in: URL)
case bodyPartFileNotReachable(at: URL)
Expand Down Expand Up @@ -159,14 +159,14 @@ public final class MultipartFormData: Sendable {

/// The content length of all body parts used to generate the `multipart/form-data` not including the boundaries.
public var contentLength: UInt64 {
lock.withLock { bodyParts.reduce(0) { $0 + $1.bodyContentLength } }
bodyParts.reduce(0) { $0 + $1.bodyContentLength }
}

/// The boundary used to separate the body parts in the encoded form data.
public let boundary: String

private let fileManager: FileManager
private let lock = NSLock()
// private let lock = NSLock()
private var bodyParts: [BodyPart] = []
private var bodyPartError: EncodingError?
private let streamBufferSize: Int
Expand Down Expand Up @@ -377,9 +377,7 @@ public final class MultipartFormData: Sendable {
/// - headers: `HTTPFields` for the body part.
public func append(_ stream: InputStream, withLength length: UInt64, headers: HTTPFields) {
let bodyPart = BodyPart(headers: headers, bodyStream: stream, bodyContentLength: length)
lock.withLock {
bodyParts.append(bodyPart)
}
bodyParts.append(bodyPart)
}

// MARK: - Data Encoding
Expand All @@ -392,19 +390,17 @@ public final class MultipartFormData: Sendable {
///
/// - Returns: The encoded `Data`, if encoding is successful.
/// - Throws: An `EncodingError` if encoding encounters an error.
public func encode() throws -> Data {
public consuming func encode() throws -> Data {
if let bodyPartError {
throw bodyPartError
}

var encoded = Data()

lock.withLock {
bodyParts.first?.hasInitialBoundary = true
bodyParts.last?.hasFinalBoundary = true
}
bodyParts.first?.hasInitialBoundary = true
bodyParts.last?.hasFinalBoundary = true

let parts = lock.withLock { bodyParts }
let parts = bodyParts
for bodyPart in parts {
let encodedData = try encode(bodyPart)
encoded.append(encodedData)
Expand All @@ -420,7 +416,7 @@ public final class MultipartFormData: Sendable {
///
/// - Parameter fileURL: File `URL` to which to write the form data.
/// - Throws: An `EncodingError` if encoding encounters an error.
public func writeEncodedData(to fileURL: URL) throws {
public consuming func writeEncodedData(to fileURL: URL) throws {
if let bodyPartError {
throw bodyPartError
}
Expand All @@ -438,12 +434,10 @@ public final class MultipartFormData: Sendable {
outputStream.open()
defer { outputStream.close() }

lock.withLock {
bodyParts.first?.hasInitialBoundary = true
bodyParts.last?.hasFinalBoundary = true
}
bodyParts.first?.hasInitialBoundary = true
bodyParts.last?.hasFinalBoundary = true

let parts = lock.withLock { bodyParts }
let parts = bodyParts
for bodyPart in parts {
try write(bodyPart, to: outputStream)
}
Expand Down Expand Up @@ -634,10 +628,8 @@ public final class MultipartFormData: Sendable {
// MARK: - Private - Errors

private func setBodyPartError(withReason reason: EncodingError.Reason) {
lock.withLock {
guard bodyPartError == nil else { return }
bodyPartError = EncodingError(reason: reason)
}
guard bodyPartError == nil else { return }
bodyPartError = EncodingError(reason: reason)
}
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/HTTPClient/Middlewares/LoggingMiddleware.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ public struct LoggingMiddleware: ClientMiddleware {

public func intercept(
_ request: HTTPRequest,
body: HTTPBody?,
body: sending HTTPBody?,
baseURL: URL,
next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
) async throws -> (HTTPResponse, HTTPBody?) {
var logger = logger
if includeMetadata, logger[metadataKey: "request-id"] == nil {
Expand Down
49 changes: 25 additions & 24 deletions Tests/HTTPClientTests/ClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ struct ClientTests {
struct MockTransport: ClientTransport {
let responseStatus: HTTPResponse.Status
let responseBody: String?
var onSend: ((HTTPRequest, HTTPBody?, URL) -> Void)?
var onSend: (@Sendable (HTTPRequest, HTTPBody?, URL) -> Void)?

init(
responseStatus: HTTPResponse.Status = .ok,
responseBody: String? = nil,
onSend: ((HTTPRequest, HTTPBody?, URL) -> Void)? = nil
onSend: (@Sendable (HTTPRequest, HTTPBody?, URL) -> Void)? = nil
) {
self.responseStatus = responseStatus
self.responseBody = responseBody
Expand All @@ -27,7 +27,7 @@ struct ClientTests {

func send(
_ request: HTTPRequest,
body: HTTPBody?,
body: sending HTTPBody?,
baseURL: URL
) async throws -> (HTTPResponse, HTTPBody?) {
onSend?(request, body, baseURL)
Expand All @@ -44,7 +44,7 @@ struct ClientTests {

func send(
_ request: HTTPRequest,
body: HTTPBody?,
body: sending HTTPBody?,
baseURL: URL
) async throws -> (HTTPResponse, HTTPBody?) {
throw TransportFailure()
Expand All @@ -54,17 +54,17 @@ struct ClientTests {
/// A mock middleware for testing
struct MockMiddleware: ClientMiddleware {
let id: String
var onIntercept: ((HTTPRequest, HTTPBody?, URL) -> Void)?
var onIntercept: (@Sendable (HTTPRequest, HTTPBody?, URL) -> Void)?
var shouldModifyRequest: Bool = false
var shouldFail: Bool = false

struct MiddlewareError: Error {}

func intercept(
_ request: HTTPRequest,
body: HTTPBody?,
body: sending HTTPBody?,
baseURL: URL,
next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
) async throws -> (HTTPResponse, HTTPBody?) {
onIntercept?(request, body, baseURL)

Expand Down Expand Up @@ -125,14 +125,15 @@ struct ClientTests {

@Test func sendRequestWithBody() async throws {
let serverURL = URL(string: "https://api.example.com")!
var capturedBody: String?
let capturedBody = SendableBox<String?>(nil)

let transport = MockTransport(
responseStatus: .created,
onSend: { _, body, _ in
Task {
if let body = body {
capturedBody = try? await String(collecting: body, upTo: 1024)
let value = try? await String(collecting: body, upTo: 1024)
capturedBody.withLock { $0 = value }
}
}
}
Expand All @@ -151,11 +152,11 @@ struct ClientTests {

@Test func clientPassesBaseURLToTransport() async throws {
let serverURL = URL(string: "https://api.example.com")!
var capturedBaseURL: URL?
let capturedBaseURL = SendableBox<URL?>(nil)

let transport = MockTransport(
onSend: { _, _, baseURL in
capturedBaseURL = baseURL
capturedBaseURL.withLock { $0 = baseURL }
}
)

Expand All @@ -164,32 +165,32 @@ struct ClientTests {
let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test"))
_ = try await client.send(request)

#expect(capturedBaseURL == serverURL)
#expect(capturedBaseURL.value == serverURL)
}

// MARK: - Middleware Tests

@Test func middlewareExecutionOrder() async throws {
let serverURL = URL(string: "https://api.example.com")!
var executionOrder: [String] = []
let executionOrder = SendableBox<[String]>([])

let middleware1 = MockMiddleware(
id: "m1",
onIntercept: { _, _, _ in
executionOrder.append("m1")
executionOrder.withLock { $0.append("m1") }
}
)

let middleware2 = MockMiddleware(
id: "m2",
onIntercept: { _, _, _ in
executionOrder.append("m2")
executionOrder.withLock { $0.append("m2") }
}
)

let transport = MockTransport(
onSend: { _, _, _ in
executionOrder.append("transport")
executionOrder.withLock { $0.append("transport") }
}
)

Expand All @@ -203,17 +204,17 @@ struct ClientTests {
_ = try await client.send(request)

// Middlewares should execute in order, then transport
#expect(executionOrder == ["m1", "m2", "transport"])
#expect(executionOrder.value == ["m1", "m2", "transport"])
}

@Test func middlewareCanModifyRequest() async throws {
let serverURL = URL(string: "https://api.example.com")!
var capturedRequest: HTTPRequest?
let capturedRequest = SendableBox<HTTPRequest?>(nil)

let middleware = MockMiddleware(id: "test", shouldModifyRequest: true)
let transport = MockTransport(
onSend: { request, _, _ in
capturedRequest = request
capturedRequest.withLock { $0 = request }
}
)

Expand All @@ -226,19 +227,19 @@ struct ClientTests {
let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test"))
_ = try await client.send(request)

#expect(capturedRequest?.headerFields[.init("X-Middleware")!] == "test")
#expect(capturedRequest.value?.headerFields[.init("X-Middleware")!] == "test")
}

@Test func multipleMiddlewaresCanChainModifications() async throws {
let serverURL = URL(string: "https://api.example.com")!
var capturedRequest: HTTPRequest?
let capturedRequest = SendableBox<HTTPRequest?>(nil)

let middleware1 = MockMiddleware(id: "first", shouldModifyRequest: true)
let middleware2 = MockMiddleware(id: "second", shouldModifyRequest: true)

let transport = MockTransport(
onSend: { request, _, _ in
capturedRequest = request
capturedRequest.withLock { $0 = request }
}
)

Expand All @@ -253,7 +254,7 @@ struct ClientTests {

// Both middlewares should have modified the request
// Note: The test middleware adds the same header, so we only see the last one
#expect(capturedRequest?.headerFields[.init("X-Middleware")!] != nil)
#expect(capturedRequest.value?.headerFields[.init("X-Middleware")!] != nil)
}

// MARK: - Error Handling Tests
Expand Down Expand Up @@ -395,6 +396,6 @@ struct ClientTests {
}

// If we got here without crashing, concurrent access works
#expect(true)
#expect(Bool(true))
}
}
Loading
Loading