From 898c3895b5ff2eed336a4860aec99cd8622e4898 Mon Sep 17 00:00:00 2001 From: Alexis Bronchart Date: Thu, 8 Oct 2026 16:02:49 +0200 Subject: [PATCH 1/8] feat(transport): add optional request interception before task creation --- .../URLSession+Extensions.swift | 7 +- .../URLSessionTransport.swift | 46 ++++++++-- .../URLSessionTransportTests.swift | 88 ++++++++++++++++++- 3 files changed, 129 insertions(+), 12 deletions(-) diff --git a/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift b/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift index 4cd091f..b998f7a 100644 --- a/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift +++ b/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift @@ -22,9 +22,12 @@ import Foundation baseURL: URL, requestBody: HTTPBody?, requestStreamBufferSize: Int, - responseStreamWatermarks: (low: Int, high: Int) + responseStreamWatermarks: (low: Int, high: Int), + interceptRequest: (@Sendable (inout URLRequest) throws -> Void)? = nil ) async throws -> (HTTPResponse, HTTPBody?) { - let urlRequest = try URLRequest(request, baseURL: baseURL) + var urlRequest = try URLRequest(request, baseURL: baseURL) + try interceptRequest?(&urlRequest) + try Task.checkCancellation() let task: URLSessionTask if requestBody != nil { task = uploadTask(withStreamedRequest: urlRequest) diff --git a/Sources/OpenAPIURLSession/URLSessionTransport.swift b/Sources/OpenAPIURLSession/URLSessionTransport.swift index 65dd92e..63bc472 100644 --- a/Sources/OpenAPIURLSession/URLSessionTransport.swift +++ b/Sources/OpenAPIURLSession/URLSessionTransport.swift @@ -66,14 +66,33 @@ public struct URLSessionTransport: ClientTransport { /// The URLSession used for performing HTTP operations. public var session: URLSession + /// An optional closure that modifies a request immediately before its task is created. + /// + /// The closure runs for both buffered and streaming requests. In streaming mode, the + /// request body is provided separately and is not available through `httpBody`. + /// Throwing prevents the task from being created. Defaults to `nil`. + /// The closure is not invoked again for redirects handled by URLSession. + /// + /// For example, to add a request header: + /// + /// let configuration = URLSessionTransport.Configuration(interceptRequest: { request in + /// request.setValue("custom-value", forHTTPHeaderField: "X-Custom-Header") + /// }) + public var interceptRequest: (@Sendable (inout URLRequest) throws -> Void)? + /// Creates a new configuration with the provided session. /// - Parameters: /// - session: The URLSession used for performing HTTP operations. /// If none is provided, the system uses the shared URLSession. /// - httpBodyProcessingMode: The mode used to process HTTP request and response bodies. - public init(session: URLSession = .shared, httpBodyProcessingMode: HTTPBodyProcessingMode = .platformDefault) { + /// - interceptRequest: An optional closure that modifies each request before its task is created. + public init( + session: URLSession = .shared, + httpBodyProcessingMode: HTTPBodyProcessingMode = .platformDefault, + interceptRequest: (@Sendable (inout URLRequest) throws -> Void)? = nil + ) { let implementation = httpBodyProcessingMode.implementation - self.init(session: session, implementation: implementation) + self.init(session: session, implementation: implementation, interceptRequest: interceptRequest) } /// Creates a new configuration with the provided session. /// - Parameter session: The URLSession used for performing HTTP operations. @@ -100,8 +119,13 @@ public struct URLSessionTransport: ClientTransport { var implementation: Implementation - init(session: URLSession = .shared, implementation: Implementation = .platformDefault) { + init( + session: URLSession = .shared, + implementation: Implementation, + interceptRequest: (@Sendable (inout URLRequest) throws -> Void)? = nil + ) { self.session = session + self.interceptRequest = interceptRequest if case .streaming = implementation { precondition(Implementation.platformSupportsStreaming, "Streaming not supported on platform") } @@ -138,7 +162,8 @@ public struct URLSessionTransport: ClientTransport { baseURL: baseURL, requestBody: requestBody, requestStreamBufferSize: requestBodyStreamBufferSize, - responseStreamWatermarks: responseBodyStreamWatermarks + responseStreamWatermarks: responseBodyStreamWatermarks, + interceptRequest: configuration.interceptRequest ) #else throw URLSessionTransportError.streamingNotSupported @@ -147,7 +172,8 @@ public struct URLSessionTransport: ClientTransport { return try await configuration.session.bufferedRequest( for: request, baseURL: baseURL, - requestBody: requestBody + requestBody: requestBody, + interceptRequest: configuration.interceptRequest ) } } @@ -313,12 +339,16 @@ func debug(_ message: @autoclosure () -> String, function: String = #function, f } extension URLSession { - func bufferedRequest(for request: HTTPRequest, baseURL: URL, requestBody: HTTPBody?) async throws -> ( - HTTPResponse, HTTPBody? - ) { + func bufferedRequest( + for request: HTTPRequest, + baseURL: URL, + requestBody: HTTPBody?, + interceptRequest: (@Sendable (inout URLRequest) throws -> Void)? = nil + ) async throws -> (HTTPResponse, HTTPBody?) { try Task.checkCancellation() var urlRequest = try URLRequest(request, baseURL: baseURL) if let requestBody { urlRequest.httpBody = try await Data(collecting: requestBody, upTo: .max) } + try interceptRequest?(&urlRequest) try Task.checkCancellation() /// Use `dataTask(with:completionHandler:)` here because `data(for:[delegate:]) async` is only available on diff --git a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift index ffacd53..76fa50b 100644 --- a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift +++ b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift @@ -76,6 +76,18 @@ class URLSessionTransportBufferedTests: XCTestCase { func testBasicPost() async throws { try await testHTTPBasicPost(transport: transport) } + func testInterceptGetRequest() async throws { + try await testRequestInterception(transport: transport, withBody: false) + } + + func testInterceptPostRequest() async throws { + try await testRequestInterception(transport: transport, withBody: true) + } + + func testThrowingRequestInterceptor() async throws { + try await checkThrowingRequestInterceptor(transport: transport) + } + #if canImport(Darwin) // Only passes on Darwin because Linux doesn't replay the request body on 307. func testHTTPRedirect_multipleIterationBehavior_succeeds() async throws { try await testHTTPRedirect( @@ -117,6 +129,18 @@ class URLSessionTransportStreamingTests: XCTestCase { func testBasicPost() async throws { try await testHTTPBasicPost(transport: transport) } + func testInterceptGetRequest() async throws { + try await testRequestInterception(transport: transport, withBody: false) + } + + func testInterceptPostRequest() async throws { + try await testRequestInterception(transport: transport, withBody: true) + } + + func testThrowingRequestInterceptor() async throws { + try await checkThrowingRequestInterceptor(transport: transport) + } + #if canImport(Darwin) // Only passes on Darwin because Linux doesn't replay the request body on 307. func testHTTPRedirect_multipleIterationBehavior_succeeds() async throws { try await testHTTPRedirect( @@ -218,7 +242,47 @@ func testHTTPRedirect( } } -func testHTTPBasicGet(transport: any ClientTransport) async throws { +private func testRequestInterception(transport: URLSessionTransport, withBody: Bool) async throws { + var transport = transport + let expectedMethod = withBody ? "POST" : "GET" + let expectsBufferedBody: Bool + if case .buffering = transport.configuration.implementation { + expectsBufferedBody = withBody + } else { + expectsBufferedBody = false + } + transport.configuration.interceptRequest = { request in + XCTAssertEqual(request.httpMethod, expectedMethod) + XCTAssertEqual(request.url?.path, "/hello/world") + if expectsBufferedBody { XCTAssertEqual(request.httpBody, Data("Hello, world!".utf8)) } + request.setValue("yes", forHTTPHeaderField: "x-intercepted") + } + if withBody { + try await testHTTPBasicPost(transport: transport, expectedInterceptedHeader: "yes") + } else { + try await testHTTPBasicGet(transport: transport, expectedInterceptedHeader: "yes") + } +} + +private enum RequestInterceptorError: Error { case rejected } + +private func checkThrowingRequestInterceptor(transport: URLSessionTransport) async throws { + var transport = transport + transport.configuration.interceptRequest = { _ in throw RequestInterceptorError.rejected } + do { + _ = try await transport.send( + HTTPRequest(method: .get, scheme: nil, authority: nil, path: "/"), + body: nil, + baseURL: URL(string: "http://127.0.0.1:1")!, + operationID: "unused" + ) + XCTFail("Expected the interceptor error") + } catch RequestInterceptorError.rejected { + // Expected: the interceptor rejects the request before a network task is created. + } +} + +func testHTTPBasicGet(transport: any ClientTransport, expectedInterceptedHeader: String? = nil) async throws { let requestPath = "/hello/world" let responseBodyMessage = "Hey!" @@ -231,6 +295,9 @@ func testHTTPBasicGet(transport: any ClientTransport) async throws { case .head(let head): XCTAssertEqual(head.uri, requestPath) XCTAssertEqual(head.method, .GET) + if let expectedInterceptedHeader { + XCTAssertEqual(head.headers.first(name: "x-intercepted"), expectedInterceptedHeader) + } case .body: XCTFail("Didn't expect any request body bytes.") case .end: try await outbound.write(.head(.init(version: .http1_1, status: .ok))) @@ -259,7 +326,7 @@ func testHTTPBasicGet(transport: any ClientTransport) async throws { } } -func testHTTPBasicPost(transport: any ClientTransport) async throws { +func testHTTPBasicPost(transport: any ClientTransport, expectedInterceptedHeader: String? = nil) async throws { let requestPath = "/hello/world" let requestBodyMessage = "Hello, world!" let responseBodyMessage = "Hey!" @@ -274,6 +341,9 @@ func testHTTPBasicPost(transport: any ClientTransport) async throws { case .head(let head): XCTAssertEqual(head.uri, requestPath) XCTAssertEqual(head.method, .POST) + if let expectedInterceptedHeader { + XCTAssertEqual(head.headers.first(name: "x-intercepted"), expectedInterceptedHeader) + } case .body(let buffer): accumulatedBody.writeImmutableBuffer(buffer) case .end: XCTAssertEqual(accumulatedBody, ByteBuffer(string: requestBodyMessage)) @@ -305,6 +375,20 @@ func testHTTPBasicPost(transport: any ClientTransport) async throws { #endif class URLSessionTransportPlatformSupportTests: XCTestCase { + func testRequestInterceptorDefaultsToNil() { + XCTAssertNil(URLSessionTransport.Configuration().interceptRequest) + XCTAssertNil(URLSessionTransport.Configuration(httpBodyProcessingMode: .buffered).interceptRequest) + } + + func testRequestInterceptorInitializer() throws { + let configuration = URLSessionTransport.Configuration(interceptRequest: { request in + request.setValue("yes", forHTTPHeaderField: "x-intercepted") + }) + var request = URLRequest(url: URL(string: "http://example.com")!) + try XCTUnwrap(configuration.interceptRequest)(&request) + XCTAssertEqual(request.value(forHTTPHeaderField: "x-intercepted"), "yes") + } + func testDefaultsToStreamingIfSupported() { if URLSessionTransport.Configuration.Implementation.platformSupportsStreaming { guard case .streaming = URLSessionTransport.Configuration.Implementation.platformDefault else { From eff2cb1715bbfcee49f0c57ad8b0bc495f4ec6ba Mon Sep 17 00:00:00 2001 From: Alexis Bronchart Date: Fri, 9 Oct 2026 11:28:23 +0200 Subject: [PATCH 2/8] docs(transport): demonstrate HTTP/3 request interception --- Sources/OpenAPIURLSession/URLSessionTransport.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIURLSession/URLSessionTransport.swift b/Sources/OpenAPIURLSession/URLSessionTransport.swift index 63bc472..85d15d9 100644 --- a/Sources/OpenAPIURLSession/URLSessionTransport.swift +++ b/Sources/OpenAPIURLSession/URLSessionTransport.swift @@ -73,10 +73,15 @@ public struct URLSessionTransport: ClientTransport { /// Throwing prevents the task from being created. Defaults to `nil`. /// The closure is not invoked again for redirects handled by URLSession. /// - /// For example, to add a request header: + /// For example, to configure a Foundation-specific request property for a server + /// known to support HTTP/3: /// /// let configuration = URLSessionTransport.Configuration(interceptRequest: { request in - /// request.setValue("custom-value", forHTTPHeaderField: "X-Custom-Header") + /// #if canImport(Darwin) + /// if #available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *) { + /// request.assumesHTTP3Capable = true + /// } + /// #endif /// }) public var interceptRequest: (@Sendable (inout URLRequest) throws -> Void)? From f3f57e6cc508b09c10fef03b8490d77715cb95df Mon Sep 17 00:00:00 2001 From: Alexis Bronchart Date: Tue, 13 Oct 2026 15:53:36 +0200 Subject: [PATCH 3/8] fix(transport): preserve configuration initializer compatibility --- .../OpenAPIURLSession/URLSessionTransport.swift | 16 +++++++++++++--- .../URLSessionTransportTests.swift | 6 ++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Sources/OpenAPIURLSession/URLSessionTransport.swift b/Sources/OpenAPIURLSession/URLSessionTransport.swift index 85d15d9..d80f6b8 100644 --- a/Sources/OpenAPIURLSession/URLSessionTransport.swift +++ b/Sources/OpenAPIURLSession/URLSessionTransport.swift @@ -66,7 +66,7 @@ public struct URLSessionTransport: ClientTransport { /// The URLSession used for performing HTTP operations. public var session: URLSession - /// An optional closure that modifies a request immediately before its task is created. + /// An optional closure that enables modification of a request immediately before its task is created. /// /// The closure runs for both buffered and streaming requests. In streaming mode, the /// request body is provided separately and is not available through `httpBody`. @@ -90,11 +90,21 @@ public struct URLSessionTransport: ClientTransport { /// - session: The URLSession used for performing HTTP operations. /// If none is provided, the system uses the shared URLSession. /// - httpBodyProcessingMode: The mode used to process HTTP request and response bodies. - /// - interceptRequest: An optional closure that modifies each request before its task is created. + public init(session: URLSession = .shared, httpBodyProcessingMode: HTTPBodyProcessingMode = .platformDefault) { + let implementation = httpBodyProcessingMode.implementation + self.init(session: session, implementation: implementation) + } + + /// Creates a new configuration with the provided session and request interceptor. + /// - Parameters: + /// - session: The URLSession used for performing HTTP operations. + /// If none is provided, the system uses the shared URLSession. + /// - httpBodyProcessingMode: The mode used to process HTTP request and response bodies. + /// - interceptRequest: A closure that enables modification of each request before its task is created. public init( session: URLSession = .shared, httpBodyProcessingMode: HTTPBodyProcessingMode = .platformDefault, - interceptRequest: (@Sendable (inout URLRequest) throws -> Void)? = nil + interceptRequest: @escaping @Sendable (inout URLRequest) throws -> Void ) { let implementation = httpBodyProcessingMode.implementation self.init(session: session, implementation: implementation, interceptRequest: interceptRequest) diff --git a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift index 76fa50b..e86add7 100644 --- a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift +++ b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift @@ -380,6 +380,12 @@ class URLSessionTransportPlatformSupportTests: XCTestCase { XCTAssertNil(URLSessionTransport.Configuration(httpBodyProcessingMode: .buffered).interceptRequest) } + func testExistingInitializerReference() { + let makeConfiguration = URLSessionTransport.Configuration.init(session:httpBodyProcessingMode:) + let configuration = makeConfiguration(.shared, .buffered) + XCTAssertNil(configuration.interceptRequest) + } + func testRequestInterceptorInitializer() throws { let configuration = URLSessionTransport.Configuration(interceptRequest: { request in request.setValue("yes", forHTTPHeaderField: "x-intercepted") From a2ad7bdb875c1ee8dc1c91c739bf6440e0b34897 Mon Sep 17 00:00:00 2001 From: Alexis Bronchart Date: Thu, 15 Oct 2026 09:24:31 +0200 Subject: [PATCH 4/8] refactor(transport): replace request interception with per-operation overrides --- .../URLSession+Extensions.swift | 6 +- .../URLSessionTransport.swift | 92 ++++++++----- .../URLSessionTransportTests.swift | 121 ++++++++---------- 3 files changed, 110 insertions(+), 109 deletions(-) diff --git a/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift b/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift index b998f7a..8652084 100644 --- a/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift +++ b/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift @@ -23,11 +23,9 @@ import Foundation requestBody: HTTPBody?, requestStreamBufferSize: Int, responseStreamWatermarks: (low: Int, high: Int), - interceptRequest: (@Sendable (inout URLRequest) throws -> Void)? = nil + requestOverrides: URLSessionTransport.URLRequestOverrides? = nil ) async throws -> (HTTPResponse, HTTPBody?) { - var urlRequest = try URLRequest(request, baseURL: baseURL) - try interceptRequest?(&urlRequest) - try Task.checkCancellation() + let urlRequest = try URLRequest(request, baseURL: baseURL, requestOverrides: requestOverrides) let task: URLSessionTask if requestBody != nil { task = uploadTask(withStreamedRequest: urlRequest) diff --git a/Sources/OpenAPIURLSession/URLSessionTransport.swift b/Sources/OpenAPIURLSession/URLSessionTransport.swift index d80f6b8..9882581 100644 --- a/Sources/OpenAPIURLSession/URLSessionTransport.swift +++ b/Sources/OpenAPIURLSession/URLSessionTransport.swift @@ -60,30 +60,38 @@ import class FoundationNetworking.HTTPURLResponse /// timeouts, authentication challenges, and more. public struct URLSessionTransport: ClientTransport { + /// Per-request overrides for Foundation-specific behavior. + /// + /// These options do not expose the request headers or body. Use `ClientMiddleware` + /// to customize transport-independent HTTP behavior. + public struct URLRequestOverrides: Sendable { + + /// Whether the request should assume that the server supports HTTP/3. + /// + /// Set this to `true` only for a server known to support HTTP/3. This requests + /// an HTTP/3 connection attempt; it does not guarantee that HTTP/3 is used. + /// A value of `nil` leaves the default URLRequest behavior unchanged. + /// This override is applied on macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, + /// and later, including Mac Catalyst and visionOS. It is ignored on other + /// platforms and earlier operating system versions. + public var assumesHTTP3Capable: Bool? = nil + + /// Creates overrides that leave the default URLRequest behavior unchanged. + public init() {} + } + /// A set of configuration values for the URLSession transport. public struct Configuration: Sendable { /// The URLSession used for performing HTTP operations. public var session: URLSession - /// An optional closure that enables modification of a request immediately before its task is created. - /// - /// The closure runs for both buffered and streaming requests. In streaming mode, the - /// request body is provided separately and is not available through `httpBody`. - /// Throwing prevents the task from being created. Defaults to `nil`. - /// The closure is not invoked again for redirects handled by URLSession. - /// - /// For example, to configure a Foundation-specific request property for a server - /// known to support HTTP/3: + /// A closure that provides request overrides for an OpenAPI operation identifier. /// - /// let configuration = URLSessionTransport.Configuration(interceptRequest: { request in - /// #if canImport(Darwin) - /// if #available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *) { - /// request.assumesHTTP3Capable = true - /// } - /// #endif - /// }) - public var interceptRequest: (@Sendable (inout URLRequest) throws -> Void)? + /// The closure is evaluated once per call to `send`, for both buffered and streaming + /// requests. It has no access to the request headers or body. When this property + /// is `nil`, the transport does not apply any overrides. Defaults to `nil`. + public var requestOptions: (@Sendable (_ operationID: String) -> URLRequestOverrides)? /// Creates a new configuration with the provided session. /// - Parameters: @@ -94,21 +102,33 @@ public struct URLSessionTransport: ClientTransport { let implementation = httpBodyProcessingMode.implementation self.init(session: session, implementation: implementation) } - - /// Creates a new configuration with the provided session and request interceptor. + /// Creates a configuration with per-operation request overrides. + /// + /// For example, if the server used by `getGreeting` is known to support HTTP/3: + /// + /// let configuration = URLSessionTransport.Configuration(requestOptions: { operationID in + /// var overrides = URLSessionTransport.URLRequestOverrides() + /// if operationID == "getGreeting" { + /// overrides.assumesHTTP3Capable = true + /// } + /// return overrides + /// }) + /// /// - Parameters: /// - session: The URLSession used for performing HTTP operations. /// If none is provided, the system uses the shared URLSession. /// - httpBodyProcessingMode: The mode used to process HTTP request and response bodies. - /// - interceptRequest: A closure that enables modification of each request before its task is created. + /// - requestOptions: A closure that provides request overrides for each operation identifier. public init( session: URLSession = .shared, httpBodyProcessingMode: HTTPBodyProcessingMode = .platformDefault, - interceptRequest: @escaping @Sendable (inout URLRequest) throws -> Void + requestOptions: @escaping @Sendable (_ operationID: String) -> URLRequestOverrides ) { let implementation = httpBodyProcessingMode.implementation - self.init(session: session, implementation: implementation, interceptRequest: interceptRequest) + self.init(session: session, implementation: implementation) + self.requestOptions = requestOptions } + /// Creates a new configuration with the provided session. /// - Parameter session: The URLSession used for performing HTTP operations. /// If none is provided, the system uses the shared URLSession. @@ -134,13 +154,9 @@ public struct URLSessionTransport: ClientTransport { var implementation: Implementation - init( - session: URLSession = .shared, - implementation: Implementation, - interceptRequest: (@Sendable (inout URLRequest) throws -> Void)? = nil - ) { + init(session: URLSession = .shared, implementation: Implementation = .platformDefault) { self.session = session - self.interceptRequest = interceptRequest + self.requestOptions = nil if case .streaming = implementation { precondition(Implementation.platformSupportsStreaming, "Streaming not supported on platform") } @@ -166,6 +182,7 @@ public struct URLSessionTransport: ClientTransport { public func send(_ request: HTTPRequest, body requestBody: HTTPBody?, baseURL: URL, operationID: String) async throws -> (HTTPResponse, HTTPBody?) { + let requestOverrides = configuration.requestOptions?(operationID) switch configuration.implementation { case .streaming(let requestBodyStreamBufferSize, let responseBodyStreamWatermarks): #if canImport(Darwin) @@ -178,7 +195,7 @@ public struct URLSessionTransport: ClientTransport { requestBody: requestBody, requestStreamBufferSize: requestBodyStreamBufferSize, responseStreamWatermarks: responseBodyStreamWatermarks, - interceptRequest: configuration.interceptRequest + requestOverrides: requestOverrides ) #else throw URLSessionTransportError.streamingNotSupported @@ -188,7 +205,7 @@ public struct URLSessionTransport: ClientTransport { for: request, baseURL: baseURL, requestBody: requestBody, - interceptRequest: configuration.interceptRequest + requestOverrides: requestOverrides ) } } @@ -244,7 +261,8 @@ extension HTTPResponse { } extension URLRequest { - init(_ request: HTTPRequest, baseURL: URL) throws { + init(_ request: HTTPRequest, baseURL: URL, requestOverrides: URLSessionTransport.URLRequestOverrides? = nil) throws + { guard var baseUrlComponents = URLComponents(string: baseURL.absoluteString), let requestUrlComponents = URLComponents(string: request.path ?? "") else { @@ -275,6 +293,13 @@ extension URLRequest { var headerFields = [String: String](minimumCapacity: combinedFields.count) for (name, value) in combinedFields { headerFields[name.rawName] = value } self.allHTTPHeaderFields = headerFields + #if canImport(Darwin) + if #available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *), + let assumesHTTP3Capable = requestOverrides?.assumesHTTP3Capable + { + self.assumesHTTP3Capable = assumesHTTP3Capable + } + #endif } } @@ -358,12 +383,11 @@ extension URLSession { for request: HTTPRequest, baseURL: URL, requestBody: HTTPBody?, - interceptRequest: (@Sendable (inout URLRequest) throws -> Void)? = nil + requestOverrides: URLSessionTransport.URLRequestOverrides? = nil ) async throws -> (HTTPResponse, HTTPBody?) { try Task.checkCancellation() - var urlRequest = try URLRequest(request, baseURL: baseURL) + var urlRequest = try URLRequest(request, baseURL: baseURL, requestOverrides: requestOverrides) if let requestBody { urlRequest.httpBody = try await Data(collecting: requestBody, upTo: .max) } - try interceptRequest?(&urlRequest) try Task.checkCancellation() /// Use `dataTask(with:completionHandler:)` here because `data(for:[delegate:]) async` is only available on diff --git a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift index e86add7..df5ae3b 100644 --- a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift +++ b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift @@ -48,6 +48,24 @@ class URLSessionTransportConverterTests: XCTestCase { XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "X-Emoji"), "😀") } + func testRequestOverrides() throws { + let request = HTTPRequest(method: .get, scheme: nil, authority: nil, path: "/hello") + let baseURL = URL(string: "https://example.com")! + let defaultRequest = try URLRequest(request, baseURL: baseURL) + var overrides = URLSessionTransport.URLRequestOverrides() + for value: Bool? in [nil, true, false] { + overrides.assumesHTTP3Capable = value + let urlRequest = try URLRequest(request, baseURL: baseURL, requestOverrides: overrides) + XCTAssertEqual(urlRequest.url, defaultRequest.url) + XCTAssertEqual(urlRequest.httpMethod, defaultRequest.httpMethod) + #if canImport(Darwin) + if #available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *) { + XCTAssertEqual(urlRequest.assumesHTTP3Capable, value ?? defaultRequest.assumesHTTP3Capable) + } + #endif + } + } + func testResponseConversion() async throws { let urlResponse: URLResponse = HTTPURLResponse( url: URL(string: "http://example.com/api/hello%20world/Maria?greeting=Howdy")!, @@ -76,17 +94,9 @@ class URLSessionTransportBufferedTests: XCTestCase { func testBasicPost() async throws { try await testHTTPBasicPost(transport: transport) } - func testInterceptGetRequest() async throws { - try await testRequestInterception(transport: transport, withBody: false) - } - - func testInterceptPostRequest() async throws { - try await testRequestInterception(transport: transport, withBody: true) - } + func testRequestOptionsGet() async throws { try await testRequestOptions(transport: transport, withBody: false) } - func testThrowingRequestInterceptor() async throws { - try await checkThrowingRequestInterceptor(transport: transport) - } + func testRequestOptionsPost() async throws { try await testRequestOptions(transport: transport, withBody: true) } #if canImport(Darwin) // Only passes on Darwin because Linux doesn't replay the request body on 307. func testHTTPRedirect_multipleIterationBehavior_succeeds() async throws { @@ -129,17 +139,9 @@ class URLSessionTransportStreamingTests: XCTestCase { func testBasicPost() async throws { try await testHTTPBasicPost(transport: transport) } - func testInterceptGetRequest() async throws { - try await testRequestInterception(transport: transport, withBody: false) - } - - func testInterceptPostRequest() async throws { - try await testRequestInterception(transport: transport, withBody: true) - } + func testRequestOptionsGet() async throws { try await testRequestOptions(transport: transport, withBody: false) } - func testThrowingRequestInterceptor() async throws { - try await checkThrowingRequestInterceptor(transport: transport) - } + func testRequestOptionsPost() async throws { try await testRequestOptions(transport: transport, withBody: true) } #if canImport(Darwin) // Only passes on Darwin because Linux doesn't replay the request body on 307. func testHTTPRedirect_multipleIterationBehavior_succeeds() async throws { @@ -242,47 +244,27 @@ func testHTTPRedirect( } } -private func testRequestInterception(transport: URLSessionTransport, withBody: Bool) async throws { +private func testRequestOptions(transport: URLSessionTransport, withBody: Bool) async throws { var transport = transport - let expectedMethod = withBody ? "POST" : "GET" - let expectsBufferedBody: Bool - if case .buffering = transport.configuration.implementation { - expectsBufferedBody = withBody - } else { - expectsBufferedBody = false - } - transport.configuration.interceptRequest = { request in - XCTAssertEqual(request.httpMethod, expectedMethod) - XCTAssertEqual(request.url?.path, "/hello/world") - if expectsBufferedBody { XCTAssertEqual(request.httpBody, Data("Hello, world!".utf8)) } - request.setValue("yes", forHTTPHeaderField: "x-intercepted") + let called = XCTestExpectation(description: "Request options evaluated for the operation") + called.assertForOverFulfill = true + transport.configuration.requestOptions = { operationID in + XCTAssertEqual(operationID, "unused") + called.fulfill() + var overrides = URLSessionTransport.URLRequestOverrides() + overrides.assumesHTTP3Capable = true + return overrides } if withBody { - try await testHTTPBasicPost(transport: transport, expectedInterceptedHeader: "yes") + try await testHTTPBasicPost(transport: transport) } else { - try await testHTTPBasicGet(transport: transport, expectedInterceptedHeader: "yes") - } -} - -private enum RequestInterceptorError: Error { case rejected } - -private func checkThrowingRequestInterceptor(transport: URLSessionTransport) async throws { - var transport = transport - transport.configuration.interceptRequest = { _ in throw RequestInterceptorError.rejected } - do { - _ = try await transport.send( - HTTPRequest(method: .get, scheme: nil, authority: nil, path: "/"), - body: nil, - baseURL: URL(string: "http://127.0.0.1:1")!, - operationID: "unused" - ) - XCTFail("Expected the interceptor error") - } catch RequestInterceptorError.rejected { - // Expected: the interceptor rejects the request before a network task is created. + try await testHTTPBasicGet(transport: transport) } + let result = await XCTWaiter.fulfillment(of: [called], timeout: 1) + XCTAssertEqual(result, .completed) } -func testHTTPBasicGet(transport: any ClientTransport, expectedInterceptedHeader: String? = nil) async throws { +func testHTTPBasicGet(transport: any ClientTransport) async throws { let requestPath = "/hello/world" let responseBodyMessage = "Hey!" @@ -295,9 +277,6 @@ func testHTTPBasicGet(transport: any ClientTransport, expectedInterceptedHeader: case .head(let head): XCTAssertEqual(head.uri, requestPath) XCTAssertEqual(head.method, .GET) - if let expectedInterceptedHeader { - XCTAssertEqual(head.headers.first(name: "x-intercepted"), expectedInterceptedHeader) - } case .body: XCTFail("Didn't expect any request body bytes.") case .end: try await outbound.write(.head(.init(version: .http1_1, status: .ok))) @@ -326,7 +305,7 @@ func testHTTPBasicGet(transport: any ClientTransport, expectedInterceptedHeader: } } -func testHTTPBasicPost(transport: any ClientTransport, expectedInterceptedHeader: String? = nil) async throws { +func testHTTPBasicPost(transport: any ClientTransport) async throws { let requestPath = "/hello/world" let requestBodyMessage = "Hello, world!" let responseBodyMessage = "Hey!" @@ -341,9 +320,6 @@ func testHTTPBasicPost(transport: any ClientTransport, expectedInterceptedHeader case .head(let head): XCTAssertEqual(head.uri, requestPath) XCTAssertEqual(head.method, .POST) - if let expectedInterceptedHeader { - XCTAssertEqual(head.headers.first(name: "x-intercepted"), expectedInterceptedHeader) - } case .body(let buffer): accumulatedBody.writeImmutableBuffer(buffer) case .end: XCTAssertEqual(accumulatedBody, ByteBuffer(string: requestBodyMessage)) @@ -375,24 +351,27 @@ func testHTTPBasicPost(transport: any ClientTransport, expectedInterceptedHeader #endif class URLSessionTransportPlatformSupportTests: XCTestCase { - func testRequestInterceptorDefaultsToNil() { - XCTAssertNil(URLSessionTransport.Configuration().interceptRequest) - XCTAssertNil(URLSessionTransport.Configuration(httpBodyProcessingMode: .buffered).interceptRequest) + func testRequestOptionsDefaults() { + XCTAssertNil(URLSessionTransport.Configuration().requestOptions) + XCTAssertNil(URLSessionTransport.Configuration(httpBodyProcessingMode: .buffered).requestOptions) + XCTAssertNil(URLSessionTransport.URLRequestOverrides().assumesHTTP3Capable) } func testExistingInitializerReference() { let makeConfiguration = URLSessionTransport.Configuration.init(session:httpBodyProcessingMode:) let configuration = makeConfiguration(.shared, .buffered) - XCTAssertNil(configuration.interceptRequest) + XCTAssertNil(configuration.requestOptions) } - func testRequestInterceptorInitializer() throws { - let configuration = URLSessionTransport.Configuration(interceptRequest: { request in - request.setValue("yes", forHTTPHeaderField: "x-intercepted") + func testRequestOptionsInitializer() throws { + let configuration = URLSessionTransport.Configuration(requestOptions: { operationID in + var overrides = URLSessionTransport.URLRequestOverrides() + overrides.assumesHTTP3Capable = operationID == "getGreeting" ? true : nil + return overrides }) - var request = URLRequest(url: URL(string: "http://example.com")!) - try XCTUnwrap(configuration.interceptRequest)(&request) - XCTAssertEqual(request.value(forHTTPHeaderField: "x-intercepted"), "yes") + let requestOptions = try XCTUnwrap(configuration.requestOptions) + XCTAssertEqual(requestOptions("getGreeting").assumesHTTP3Capable, true) + XCTAssertNil(requestOptions("postGreeting").assumesHTTP3Capable) } func testDefaultsToStreamingIfSupported() { From 3ce3668b1be9c1aee19527ac6d242fc8e81e3fad Mon Sep 17 00:00:00 2001 From: Alexis Bronchart Date: Thu, 15 Oct 2026 09:48:33 +0200 Subject: [PATCH 5/8] refactor(transport): clarify request overrides API and documentation --- .../URLSessionTransport.swift | 68 +++++++++---------- .../URLSessionTransportTests.swift | 40 ++++++----- 2 files changed, 58 insertions(+), 50 deletions(-) diff --git a/Sources/OpenAPIURLSession/URLSessionTransport.swift b/Sources/OpenAPIURLSession/URLSessionTransport.swift index 9882581..c6eb89a 100644 --- a/Sources/OpenAPIURLSession/URLSessionTransport.swift +++ b/Sources/OpenAPIURLSession/URLSessionTransport.swift @@ -60,26 +60,6 @@ import class FoundationNetworking.HTTPURLResponse /// timeouts, authentication challenges, and more. public struct URLSessionTransport: ClientTransport { - /// Per-request overrides for Foundation-specific behavior. - /// - /// These options do not expose the request headers or body. Use `ClientMiddleware` - /// to customize transport-independent HTTP behavior. - public struct URLRequestOverrides: Sendable { - - /// Whether the request should assume that the server supports HTTP/3. - /// - /// Set this to `true` only for a server known to support HTTP/3. This requests - /// an HTTP/3 connection attempt; it does not guarantee that HTTP/3 is used. - /// A value of `nil` leaves the default URLRequest behavior unchanged. - /// This override is applied on macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, - /// and later, including Mac Catalyst and visionOS. It is ignored on other - /// platforms and earlier operating system versions. - public var assumesHTTP3Capable: Bool? = nil - - /// Creates overrides that leave the default URLRequest behavior unchanged. - public init() {} - } - /// A set of configuration values for the URLSession transport. public struct Configuration: Sendable { @@ -88,10 +68,11 @@ public struct URLSessionTransport: ClientTransport { /// A closure that provides request overrides for an OpenAPI operation identifier. /// - /// The closure is evaluated once per call to `send`, for both buffered and streaming - /// requests. It has no access to the request headers or body. When this property - /// is `nil`, the transport does not apply any overrides. Defaults to `nil`. - public var requestOptions: (@Sendable (_ operationID: String) -> URLRequestOverrides)? + /// The closure receives the operation's `operationId` from the OpenAPI document + /// and returns the overrides to apply to its request. + /// It is evaluated once per call to `send`, for both buffered and streaming requests. + /// When this property is `nil`, the transport does not apply any overrides. Defaults to `nil`. + public var requestOverrides: (@Sendable (_ operationID: String) -> URLRequestOverrides)? /// Creates a new configuration with the provided session. /// - Parameters: @@ -102,11 +83,16 @@ public struct URLSessionTransport: ClientTransport { let implementation = httpBodyProcessingMode.implementation self.init(session: session, implementation: implementation) } + /// Creates a new configuration with the provided session. + /// - Parameter session: The URLSession used for performing HTTP operations. + /// If none is provided, the system uses the shared URLSession. + public init(session: URLSession = .shared) { self.init(session: session, implementation: .platformDefault) } + /// Creates a configuration with per-operation request overrides. /// /// For example, if the server used by `getGreeting` is known to support HTTP/3: /// - /// let configuration = URLSessionTransport.Configuration(requestOptions: { operationID in + /// let configuration = URLSessionTransport.Configuration(requestOverrides: { operationID in /// var overrides = URLSessionTransport.URLRequestOverrides() /// if operationID == "getGreeting" { /// overrides.assumesHTTP3Capable = true @@ -118,21 +104,18 @@ public struct URLSessionTransport: ClientTransport { /// - session: The URLSession used for performing HTTP operations. /// If none is provided, the system uses the shared URLSession. /// - httpBodyProcessingMode: The mode used to process HTTP request and response bodies. - /// - requestOptions: A closure that provides request overrides for each operation identifier. + /// - requestOverrides: A closure that receives the operation's `operationId` from the OpenAPI + /// document and returns the overrides to apply to its request. public init( session: URLSession = .shared, httpBodyProcessingMode: HTTPBodyProcessingMode = .platformDefault, - requestOptions: @escaping @Sendable (_ operationID: String) -> URLRequestOverrides + requestOverrides: @escaping @Sendable (_ operationID: String) -> URLRequestOverrides ) { let implementation = httpBodyProcessingMode.implementation self.init(session: session, implementation: implementation) - self.requestOptions = requestOptions + self.requestOverrides = requestOverrides } - /// Creates a new configuration with the provided session. - /// - Parameter session: The URLSession used for performing HTTP operations. - /// If none is provided, the system uses the shared URLSession. - public init(session: URLSession = .shared) { self.init(session: session, implementation: .platformDefault) } /// Specifies the mode in which HTTP request and response bodies are processed. public struct HTTPBodyProcessingMode: Sendable { /// Exposing the internal implementation directly. @@ -156,7 +139,7 @@ public struct URLSessionTransport: ClientTransport { init(session: URLSession = .shared, implementation: Implementation = .platformDefault) { self.session = session - self.requestOptions = nil + self.requestOverrides = nil if case .streaming = implementation { precondition(Implementation.platformSupportsStreaming, "Streaming not supported on platform") } @@ -164,6 +147,23 @@ public struct URLSessionTransport: ClientTransport { } } + /// Per-request overrides for Foundation-specific behavior. + public struct URLRequestOverrides: Sendable { + + /// Whether the request should assume that the server supports HTTP/3. + /// + /// Set this to `true` only for a server known to support HTTP/3. This requests + /// an HTTP/3 connection attempt; it does not guarantee that HTTP/3 is used. + /// A value of `nil` leaves the default URLRequest behavior unchanged. + /// This override is applied on macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, + /// and later, including Mac Catalyst and visionOS. It is ignored on other + /// platforms and earlier operating system versions. + public var assumesHTTP3Capable: Bool? = nil + + /// Creates overrides that leave the default URLRequest behavior unchanged. + public init() {} + } + /// A set of configuration values used by the transport. public var configuration: Configuration @@ -182,7 +182,7 @@ public struct URLSessionTransport: ClientTransport { public func send(_ request: HTTPRequest, body requestBody: HTTPBody?, baseURL: URL, operationID: String) async throws -> (HTTPResponse, HTTPBody?) { - let requestOverrides = configuration.requestOptions?(operationID) + let requestOverrides = configuration.requestOverrides?(operationID) switch configuration.implementation { case .streaming(let requestBodyStreamBufferSize, let responseBodyStreamWatermarks): #if canImport(Darwin) diff --git a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift index df5ae3b..8af4c25 100644 --- a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift +++ b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift @@ -94,9 +94,13 @@ class URLSessionTransportBufferedTests: XCTestCase { func testBasicPost() async throws { try await testHTTPBasicPost(transport: transport) } - func testRequestOptionsGet() async throws { try await testRequestOptions(transport: transport, withBody: false) } + func testRequestOverridesGet() async throws { + try await testRequestOverrides(transport: transport, withBody: false) + } - func testRequestOptionsPost() async throws { try await testRequestOptions(transport: transport, withBody: true) } + func testRequestOverridesPost() async throws { + try await testRequestOverrides(transport: transport, withBody: true) + } #if canImport(Darwin) // Only passes on Darwin because Linux doesn't replay the request body on 307. func testHTTPRedirect_multipleIterationBehavior_succeeds() async throws { @@ -139,9 +143,13 @@ class URLSessionTransportStreamingTests: XCTestCase { func testBasicPost() async throws { try await testHTTPBasicPost(transport: transport) } - func testRequestOptionsGet() async throws { try await testRequestOptions(transport: transport, withBody: false) } + func testRequestOverridesGet() async throws { + try await testRequestOverrides(transport: transport, withBody: false) + } - func testRequestOptionsPost() async throws { try await testRequestOptions(transport: transport, withBody: true) } + func testRequestOverridesPost() async throws { + try await testRequestOverrides(transport: transport, withBody: true) + } #if canImport(Darwin) // Only passes on Darwin because Linux doesn't replay the request body on 307. func testHTTPRedirect_multipleIterationBehavior_succeeds() async throws { @@ -244,11 +252,11 @@ func testHTTPRedirect( } } -private func testRequestOptions(transport: URLSessionTransport, withBody: Bool) async throws { +private func testRequestOverrides(transport: URLSessionTransport, withBody: Bool) async throws { var transport = transport - let called = XCTestExpectation(description: "Request options evaluated for the operation") + let called = XCTestExpectation(description: "Request overrides evaluated for the operation") called.assertForOverFulfill = true - transport.configuration.requestOptions = { operationID in + transport.configuration.requestOverrides = { operationID in XCTAssertEqual(operationID, "unused") called.fulfill() var overrides = URLSessionTransport.URLRequestOverrides() @@ -351,27 +359,27 @@ func testHTTPBasicPost(transport: any ClientTransport) async throws { #endif class URLSessionTransportPlatformSupportTests: XCTestCase { - func testRequestOptionsDefaults() { - XCTAssertNil(URLSessionTransport.Configuration().requestOptions) - XCTAssertNil(URLSessionTransport.Configuration(httpBodyProcessingMode: .buffered).requestOptions) + func testRequestOverridesDefaults() { + XCTAssertNil(URLSessionTransport.Configuration().requestOverrides) + XCTAssertNil(URLSessionTransport.Configuration(httpBodyProcessingMode: .buffered).requestOverrides) XCTAssertNil(URLSessionTransport.URLRequestOverrides().assumesHTTP3Capable) } func testExistingInitializerReference() { let makeConfiguration = URLSessionTransport.Configuration.init(session:httpBodyProcessingMode:) let configuration = makeConfiguration(.shared, .buffered) - XCTAssertNil(configuration.requestOptions) + XCTAssertNil(configuration.requestOverrides) } - func testRequestOptionsInitializer() throws { - let configuration = URLSessionTransport.Configuration(requestOptions: { operationID in + func testRequestOverridesInitializer() throws { + let configuration = URLSessionTransport.Configuration(requestOverrides: { operationID in var overrides = URLSessionTransport.URLRequestOverrides() overrides.assumesHTTP3Capable = operationID == "getGreeting" ? true : nil return overrides }) - let requestOptions = try XCTUnwrap(configuration.requestOptions) - XCTAssertEqual(requestOptions("getGreeting").assumesHTTP3Capable, true) - XCTAssertNil(requestOptions("postGreeting").assumesHTTP3Capable) + let requestOverrides = try XCTUnwrap(configuration.requestOverrides) + XCTAssertEqual(requestOverrides("getGreeting").assumesHTTP3Capable, true) + XCTAssertNil(requestOverrides("postGreeting").assumesHTTP3Capable) } func testDefaultsToStreamingIfSupported() { From 56d1c22bf1957c593a7cffb5b1a853d17ec307ae Mon Sep 17 00:00:00 2001 From: Alexis Bronchart Date: Fri, 16 Oct 2026 12:35:34 +0200 Subject: [PATCH 6/8] refactor(transport): nest request overrides and consolidate configuration initializers --- .../URLSession+Extensions.swift | 2 +- .../URLSessionTransport.swift | 73 ++++++++++--------- .../URLSessionTransportTests.swift | 34 ++++++++- 3 files changed, 68 insertions(+), 41 deletions(-) diff --git a/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift b/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift index 8652084..057cc8b 100644 --- a/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift +++ b/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift @@ -23,7 +23,7 @@ import Foundation requestBody: HTTPBody?, requestStreamBufferSize: Int, responseStreamWatermarks: (low: Int, high: Int), - requestOverrides: URLSessionTransport.URLRequestOverrides? = nil + requestOverrides: URLSessionTransport.Configuration.RequestOverrides? = nil ) async throws -> (HTTPResponse, HTTPBody?) { let urlRequest = try URLRequest(request, baseURL: baseURL, requestOverrides: requestOverrides) let task: URLSessionTask diff --git a/Sources/OpenAPIURLSession/URLSessionTransport.swift b/Sources/OpenAPIURLSession/URLSessionTransport.swift index c6eb89a..f8e5f01 100644 --- a/Sources/OpenAPIURLSession/URLSessionTransport.swift +++ b/Sources/OpenAPIURLSession/URLSessionTransport.swift @@ -72,7 +72,7 @@ public struct URLSessionTransport: ClientTransport { /// and returns the overrides to apply to its request. /// It is evaluated once per call to `send`, for both buffered and streaming requests. /// When this property is `nil`, the transport does not apply any overrides. Defaults to `nil`. - public var requestOverrides: (@Sendable (_ operationID: String) -> URLRequestOverrides)? + public var requestOverrides: (@Sendable (_ operationID: String) -> RequestOverrides)? /// Creates a new configuration with the provided session. /// - Parameters: @@ -90,16 +90,6 @@ public struct URLSessionTransport: ClientTransport { /// Creates a configuration with per-operation request overrides. /// - /// For example, if the server used by `getGreeting` is known to support HTTP/3: - /// - /// let configuration = URLSessionTransport.Configuration(requestOverrides: { operationID in - /// var overrides = URLSessionTransport.URLRequestOverrides() - /// if operationID == "getGreeting" { - /// overrides.assumesHTTP3Capable = true - /// } - /// return overrides - /// }) - /// /// - Parameters: /// - session: The URLSession used for performing HTTP operations. /// If none is provided, the system uses the shared URLSession. @@ -109,11 +99,32 @@ public struct URLSessionTransport: ClientTransport { public init( session: URLSession = .shared, httpBodyProcessingMode: HTTPBodyProcessingMode = .platformDefault, - requestOverrides: @escaping @Sendable (_ operationID: String) -> URLRequestOverrides + requestOverrides: (@Sendable (_ operationID: String) -> RequestOverrides)? = nil ) { let implementation = httpBodyProcessingMode.implementation - self.init(session: session, implementation: implementation) - self.requestOverrides = requestOverrides + self.init(session: session, implementation: implementation, requestOverrides: requestOverrides) + } + + /// Per-request overrides for Foundation-specific behavior. + /// + /// For example, if the server used by `getGreeting` is known to support HTTP/3: + /// + /// let configuration = URLSessionTransport.Configuration(requestOverrides: { operationID in + /// var overrides = URLSessionTransport.Configuration.RequestOverrides.empty + /// if operationID == "getGreeting" { + /// overrides.assumesHTTP3Capable = true + /// } + /// return overrides + /// }) + /// + public struct RequestOverrides: Sendable { + + /// Whether the request should assume that the server supports HTTP/3, if supported by the platform. + /// A value of `nil` leaves the default URLRequest behavior unchanged. + public var assumesHTTP3Capable: Bool? = nil + + /// Overrides that leave the default URLRequest behavior unchanged. + public static var empty: Self { .init() } } /// Specifies the mode in which HTTP request and response bodies are processed. @@ -137,9 +148,13 @@ public struct URLSessionTransport: ClientTransport { var implementation: Implementation - init(session: URLSession = .shared, implementation: Implementation = .platformDefault) { + init( + session: URLSession = .shared, + implementation: Implementation, + requestOverrides: (@Sendable (_ operationID: String) -> RequestOverrides)? = nil + ) { self.session = session - self.requestOverrides = nil + self.requestOverrides = requestOverrides if case .streaming = implementation { precondition(Implementation.platformSupportsStreaming, "Streaming not supported on platform") } @@ -147,23 +162,6 @@ public struct URLSessionTransport: ClientTransport { } } - /// Per-request overrides for Foundation-specific behavior. - public struct URLRequestOverrides: Sendable { - - /// Whether the request should assume that the server supports HTTP/3. - /// - /// Set this to `true` only for a server known to support HTTP/3. This requests - /// an HTTP/3 connection attempt; it does not guarantee that HTTP/3 is used. - /// A value of `nil` leaves the default URLRequest behavior unchanged. - /// This override is applied on macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, - /// and later, including Mac Catalyst and visionOS. It is ignored on other - /// platforms and earlier operating system versions. - public var assumesHTTP3Capable: Bool? = nil - - /// Creates overrides that leave the default URLRequest behavior unchanged. - public init() {} - } - /// A set of configuration values used by the transport. public var configuration: Configuration @@ -261,8 +259,11 @@ extension HTTPResponse { } extension URLRequest { - init(_ request: HTTPRequest, baseURL: URL, requestOverrides: URLSessionTransport.URLRequestOverrides? = nil) throws - { + init( + _ request: HTTPRequest, + baseURL: URL, + requestOverrides: URLSessionTransport.Configuration.RequestOverrides? = nil + ) throws { guard var baseUrlComponents = URLComponents(string: baseURL.absoluteString), let requestUrlComponents = URLComponents(string: request.path ?? "") else { @@ -383,7 +384,7 @@ extension URLSession { for request: HTTPRequest, baseURL: URL, requestBody: HTTPBody?, - requestOverrides: URLSessionTransport.URLRequestOverrides? = nil + requestOverrides: URLSessionTransport.Configuration.RequestOverrides? = nil ) async throws -> (HTTPResponse, HTTPBody?) { try Task.checkCancellation() var urlRequest = try URLRequest(request, baseURL: baseURL, requestOverrides: requestOverrides) diff --git a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift index 8af4c25..ebf0ff6 100644 --- a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift +++ b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift @@ -52,7 +52,7 @@ class URLSessionTransportConverterTests: XCTestCase { let request = HTTPRequest(method: .get, scheme: nil, authority: nil, path: "/hello") let baseURL = URL(string: "https://example.com")! let defaultRequest = try URLRequest(request, baseURL: baseURL) - var overrides = URLSessionTransport.URLRequestOverrides() + var overrides = URLSessionTransport.Configuration.RequestOverrides.empty for value: Bool? in [nil, true, false] { overrides.assumesHTTP3Capable = value let urlRequest = try URLRequest(request, baseURL: baseURL, requestOverrides: overrides) @@ -259,7 +259,7 @@ private func testRequestOverrides(transport: URLSessionTransport, withBody: Bool transport.configuration.requestOverrides = { operationID in XCTAssertEqual(operationID, "unused") called.fulfill() - var overrides = URLSessionTransport.URLRequestOverrides() + var overrides = URLSessionTransport.Configuration.RequestOverrides.empty overrides.assumesHTTP3Capable = true return overrides } @@ -362,18 +362,44 @@ class URLSessionTransportPlatformSupportTests: XCTestCase { func testRequestOverridesDefaults() { XCTAssertNil(URLSessionTransport.Configuration().requestOverrides) XCTAssertNil(URLSessionTransport.Configuration(httpBodyProcessingMode: .buffered).requestOverrides) - XCTAssertNil(URLSessionTransport.URLRequestOverrides().assumesHTTP3Capable) + XCTAssertNil(URLSessionTransport.Configuration.RequestOverrides.empty.assumesHTTP3Capable) } func testExistingInitializerReference() { let makeConfiguration = URLSessionTransport.Configuration.init(session:httpBodyProcessingMode:) let configuration = makeConfiguration(.shared, .buffered) XCTAssertNil(configuration.requestOverrides) + let makeSessionConfiguration = URLSessionTransport.Configuration.init(session:) + XCTAssertNil(makeSessionConfiguration(.shared).requestOverrides) + } + + func testOptionalRequestOverridesInitializer() throws { + let requestOverrides: (@Sendable (String) -> URLSessionTransport.Configuration.RequestOverrides)? = nil + let configuration = URLSessionTransport.Configuration(requestOverrides: requestOverrides) + XCTAssertNil(configuration.requestOverrides) + XCTAssertNil(URLSessionTransport.Configuration(session: .shared, requestOverrides: nil).requestOverrides) + XCTAssertNil( + URLSessionTransport.Configuration(httpBodyProcessingMode: .buffered, requestOverrides: nil).requestOverrides + ) + let configuredOverrides: (@Sendable (String) -> URLSessionTransport.Configuration.RequestOverrides)? = { _ in + var overrides = URLSessionTransport.Configuration.RequestOverrides.empty + overrides.assumesHTTP3Capable = false + return overrides + } + let session = URLSession(configuration: .ephemeral) + let configured = URLSessionTransport.Configuration( + session: session, + httpBodyProcessingMode: .buffered, + requestOverrides: configuredOverrides + ) + XCTAssertTrue(configured.session === session) + guard case .buffering = configured.implementation else { return XCTFail("Expected buffering") } + XCTAssertEqual(try XCTUnwrap(configured.requestOverrides)("getGreeting").assumesHTTP3Capable, false) } func testRequestOverridesInitializer() throws { let configuration = URLSessionTransport.Configuration(requestOverrides: { operationID in - var overrides = URLSessionTransport.URLRequestOverrides() + var overrides = URLSessionTransport.Configuration.RequestOverrides.empty overrides.assumesHTTP3Capable = operationID == "getGreeting" ? true : nil return overrides }) From 3a1cd90bcf9dc71343308b7261dd91e83f81bbc2 Mon Sep 17 00:00:00 2001 From: Alexis Bronchart Date: Tue, 20 Oct 2026 10:04:54 +0200 Subject: [PATCH 7/8] refactor(transport): pass request metadata to overrides closure --- .../URLSessionTransport.swift | 28 ++++++++------ .../URLSessionTransportTests.swift | 37 ++++++++++++------- 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/Sources/OpenAPIURLSession/URLSessionTransport.swift b/Sources/OpenAPIURLSession/URLSessionTransport.swift index f8e5f01..35f6def 100644 --- a/Sources/OpenAPIURLSession/URLSessionTransport.swift +++ b/Sources/OpenAPIURLSession/URLSessionTransport.swift @@ -66,13 +66,12 @@ public struct URLSessionTransport: ClientTransport { /// The URLSession used for performing HTTP operations. public var session: URLSession - /// A closure that provides request overrides for an OpenAPI operation identifier. + /// A closure that provides request overrides using metadata about an OpenAPI operation. /// - /// The closure receives the operation's `operationId` from the OpenAPI document - /// and returns the overrides to apply to its request. + /// The closure receives metadata about the operation and returns the overrides to apply to its request. /// It is evaluated once per call to `send`, for both buffered and streaming requests. /// When this property is `nil`, the transport does not apply any overrides. Defaults to `nil`. - public var requestOverrides: (@Sendable (_ operationID: String) -> RequestOverrides)? + public var requestOverrides: (@Sendable (_ metadata: RequestMetadata) -> RequestOverrides)? /// Creates a new configuration with the provided session. /// - Parameters: @@ -94,24 +93,31 @@ public struct URLSessionTransport: ClientTransport { /// - session: The URLSession used for performing HTTP operations. /// If none is provided, the system uses the shared URLSession. /// - httpBodyProcessingMode: The mode used to process HTTP request and response bodies. - /// - requestOverrides: A closure that receives the operation's `operationId` from the OpenAPI - /// document and returns the overrides to apply to its request. + /// - requestOverrides: A closure that receives metadata about the operation + /// and returns the overrides to apply to its request. public init( session: URLSession = .shared, httpBodyProcessingMode: HTTPBodyProcessingMode = .platformDefault, - requestOverrides: (@Sendable (_ operationID: String) -> RequestOverrides)? = nil + requestOverrides: (@Sendable (_ metadata: RequestMetadata) -> RequestOverrides)? = nil ) { let implementation = httpBodyProcessingMode.implementation self.init(session: session, implementation: implementation, requestOverrides: requestOverrides) } + /// Metadata about an OpenAPI operation used to determine request overrides. + public struct RequestMetadata: Sendable { + + /// The operation's `operationId` from the OpenAPI document. + public var operationID: String + } + /// Per-request overrides for Foundation-specific behavior. /// /// For example, if the server used by `getGreeting` is known to support HTTP/3: /// - /// let configuration = URLSessionTransport.Configuration(requestOverrides: { operationID in + /// let configuration = URLSessionTransport.Configuration(requestOverrides: { metadata in /// var overrides = URLSessionTransport.Configuration.RequestOverrides.empty - /// if operationID == "getGreeting" { + /// if metadata.operationID == "getGreeting" { /// overrides.assumesHTTP3Capable = true /// } /// return overrides @@ -151,7 +157,7 @@ public struct URLSessionTransport: ClientTransport { init( session: URLSession = .shared, implementation: Implementation, - requestOverrides: (@Sendable (_ operationID: String) -> RequestOverrides)? = nil + requestOverrides: (@Sendable (_ metadata: RequestMetadata) -> RequestOverrides)? = nil ) { self.session = session self.requestOverrides = requestOverrides @@ -180,7 +186,7 @@ public struct URLSessionTransport: ClientTransport { public func send(_ request: HTTPRequest, body requestBody: HTTPBody?, baseURL: URL, operationID: String) async throws -> (HTTPResponse, HTTPBody?) { - let requestOverrides = configuration.requestOverrides?(operationID) + let requestOverrides = configuration.requestOverrides?(.init(operationID: operationID)) switch configuration.implementation { case .streaming(let requestBodyStreamBufferSize, let responseBodyStreamWatermarks): #if canImport(Darwin) diff --git a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift index ebf0ff6..1213e35 100644 --- a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift +++ b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift @@ -256,8 +256,8 @@ private func testRequestOverrides(transport: URLSessionTransport, withBody: Bool var transport = transport let called = XCTestExpectation(description: "Request overrides evaluated for the operation") called.assertForOverFulfill = true - transport.configuration.requestOverrides = { operationID in - XCTAssertEqual(operationID, "unused") + transport.configuration.requestOverrides = { metadata in + XCTAssertEqual(metadata.operationID, "unused") called.fulfill() var overrides = URLSessionTransport.Configuration.RequestOverrides.empty overrides.assumesHTTP3Capable = true @@ -374,18 +374,26 @@ class URLSessionTransportPlatformSupportTests: XCTestCase { } func testOptionalRequestOverridesInitializer() throws { - let requestOverrides: (@Sendable (String) -> URLSessionTransport.Configuration.RequestOverrides)? = nil + let requestOverrides: + ( + @Sendable (URLSessionTransport.Configuration.RequestMetadata) -> + URLSessionTransport.Configuration.RequestOverrides + )? = nil let configuration = URLSessionTransport.Configuration(requestOverrides: requestOverrides) XCTAssertNil(configuration.requestOverrides) XCTAssertNil(URLSessionTransport.Configuration(session: .shared, requestOverrides: nil).requestOverrides) XCTAssertNil( URLSessionTransport.Configuration(httpBodyProcessingMode: .buffered, requestOverrides: nil).requestOverrides ) - let configuredOverrides: (@Sendable (String) -> URLSessionTransport.Configuration.RequestOverrides)? = { _ in - var overrides = URLSessionTransport.Configuration.RequestOverrides.empty - overrides.assumesHTTP3Capable = false - return overrides - } + let configuredOverrides: + ( + @Sendable (URLSessionTransport.Configuration.RequestMetadata) -> + URLSessionTransport.Configuration.RequestOverrides + )? = { _ in + var overrides = URLSessionTransport.Configuration.RequestOverrides.empty + overrides.assumesHTTP3Capable = false + return overrides + } let session = URLSession(configuration: .ephemeral) let configured = URLSessionTransport.Configuration( session: session, @@ -394,18 +402,21 @@ class URLSessionTransportPlatformSupportTests: XCTestCase { ) XCTAssertTrue(configured.session === session) guard case .buffering = configured.implementation else { return XCTFail("Expected buffering") } - XCTAssertEqual(try XCTUnwrap(configured.requestOverrides)("getGreeting").assumesHTTP3Capable, false) + XCTAssertEqual( + try XCTUnwrap(configured.requestOverrides)(.init(operationID: "getGreeting")).assumesHTTP3Capable, + false + ) } func testRequestOverridesInitializer() throws { - let configuration = URLSessionTransport.Configuration(requestOverrides: { operationID in + let configuration = URLSessionTransport.Configuration(requestOverrides: { metadata in var overrides = URLSessionTransport.Configuration.RequestOverrides.empty - overrides.assumesHTTP3Capable = operationID == "getGreeting" ? true : nil + overrides.assumesHTTP3Capable = metadata.operationID == "getGreeting" ? true : nil return overrides }) let requestOverrides = try XCTUnwrap(configuration.requestOverrides) - XCTAssertEqual(requestOverrides("getGreeting").assumesHTTP3Capable, true) - XCTAssertNil(requestOverrides("postGreeting").assumesHTTP3Capable) + XCTAssertEqual(requestOverrides(.init(operationID: "getGreeting")).assumesHTTP3Capable, true) + XCTAssertNil(requestOverrides(.init(operationID: "postGreeting")).assumesHTTP3Capable) } func testDefaultsToStreamingIfSupported() { From ea6f12117727231b1a9ef7e008bed51a795e17e4 Mon Sep 17 00:00:00 2001 From: Alexis Bronchart Date: Tue, 20 Oct 2026 16:40:03 +0200 Subject: [PATCH 8/8] fix(transport): enforce HTTP/3 override platform availability --- .../URLSessionTransport.swift | 23 ++++++++-- .../URLSessionTransportTests.swift | 44 ++++++++++++++----- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/Sources/OpenAPIURLSession/URLSessionTransport.swift b/Sources/OpenAPIURLSession/URLSessionTransport.swift index 35f6def..92f4d9a 100644 --- a/Sources/OpenAPIURLSession/URLSessionTransport.swift +++ b/Sources/OpenAPIURLSession/URLSessionTransport.swift @@ -117,17 +117,32 @@ public struct URLSessionTransport: ClientTransport { /// /// let configuration = URLSessionTransport.Configuration(requestOverrides: { metadata in /// var overrides = URLSessionTransport.Configuration.RequestOverrides.empty - /// if metadata.operationID == "getGreeting" { - /// overrides.assumesHTTP3Capable = true + /// #if canImport(Darwin) + /// if #available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *) { + /// if metadata.operationID == "getGreeting" { + /// overrides.assumesHTTP3Capable = true + /// } /// } + /// #endif /// return overrides /// }) /// public struct RequestOverrides: Sendable { - /// Whether the request should assume that the server supports HTTP/3, if supported by the platform. + private var _assumesHTTP3Capable: Bool? = nil + + /// Whether the request should assume that the server supports HTTP/3. /// A value of `nil` leaves the default URLRequest behavior unchanged. - public var assumesHTTP3Capable: Bool? = nil + #if canImport(Darwin) + @available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *) + #else + @available(*, unavailable, message: "This option is not supported on this platform") + #endif + + public var assumesHTTP3Capable: Bool? { + get { _assumesHTTP3Capable } + set { _assumesHTTP3Capable = newValue } + } /// Overrides that leave the default URLRequest behavior unchanged. public static var empty: Self { .init() } diff --git a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift index 1213e35..4beb080 100644 --- a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift +++ b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift @@ -54,7 +54,9 @@ class URLSessionTransportConverterTests: XCTestCase { let defaultRequest = try URLRequest(request, baseURL: baseURL) var overrides = URLSessionTransport.Configuration.RequestOverrides.empty for value: Bool? in [nil, true, false] { - overrides.assumesHTTP3Capable = value + #if canImport(Darwin) + if #available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *) { overrides.assumesHTTP3Capable = value } + #endif let urlRequest = try URLRequest(request, baseURL: baseURL, requestOverrides: overrides) XCTAssertEqual(urlRequest.url, defaultRequest.url) XCTAssertEqual(urlRequest.httpMethod, defaultRequest.httpMethod) @@ -260,7 +262,9 @@ private func testRequestOverrides(transport: URLSessionTransport, withBody: Bool XCTAssertEqual(metadata.operationID, "unused") called.fulfill() var overrides = URLSessionTransport.Configuration.RequestOverrides.empty - overrides.assumesHTTP3Capable = true + #if canImport(Darwin) + if #available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *) { overrides.assumesHTTP3Capable = true } + #endif return overrides } if withBody { @@ -362,7 +366,11 @@ class URLSessionTransportPlatformSupportTests: XCTestCase { func testRequestOverridesDefaults() { XCTAssertNil(URLSessionTransport.Configuration().requestOverrides) XCTAssertNil(URLSessionTransport.Configuration(httpBodyProcessingMode: .buffered).requestOverrides) - XCTAssertNil(URLSessionTransport.Configuration.RequestOverrides.empty.assumesHTTP3Capable) + #if canImport(Darwin) + if #available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *) { + XCTAssertNil(URLSessionTransport.Configuration.RequestOverrides.empty.assumesHTTP3Capable) + } + #endif } func testExistingInitializerReference() { @@ -391,7 +399,9 @@ class URLSessionTransportPlatformSupportTests: XCTestCase { URLSessionTransport.Configuration.RequestOverrides )? = { _ in var overrides = URLSessionTransport.Configuration.RequestOverrides.empty - overrides.assumesHTTP3Capable = false + #if canImport(Darwin) + if #available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *) { overrides.assumesHTTP3Capable = false } + #endif return overrides } let session = URLSession(configuration: .ephemeral) @@ -402,21 +412,33 @@ class URLSessionTransportPlatformSupportTests: XCTestCase { ) XCTAssertTrue(configured.session === session) guard case .buffering = configured.implementation else { return XCTFail("Expected buffering") } - XCTAssertEqual( - try XCTUnwrap(configured.requestOverrides)(.init(operationID: "getGreeting")).assumesHTTP3Capable, - false - ) + let overrides = try XCTUnwrap(configured.requestOverrides)(.init(operationID: "getGreeting")) + #if canImport(Darwin) + if #available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *) { + XCTAssertEqual(overrides.assumesHTTP3Capable, false) + } + #endif } func testRequestOverridesInitializer() throws { let configuration = URLSessionTransport.Configuration(requestOverrides: { metadata in var overrides = URLSessionTransport.Configuration.RequestOverrides.empty - overrides.assumesHTTP3Capable = metadata.operationID == "getGreeting" ? true : nil + #if canImport(Darwin) + if #available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *) { + overrides.assumesHTTP3Capable = metadata.operationID == "getGreeting" ? true : nil + } + #endif return overrides }) let requestOverrides = try XCTUnwrap(configuration.requestOverrides) - XCTAssertEqual(requestOverrides(.init(operationID: "getGreeting")).assumesHTTP3Capable, true) - XCTAssertNil(requestOverrides(.init(operationID: "postGreeting")).assumesHTTP3Capable) + let getOverrides = requestOverrides(.init(operationID: "getGreeting")) + let postOverrides = requestOverrides(.init(operationID: "postGreeting")) + #if canImport(Darwin) + if #available(macOS 11.3, iOS 14.5, tvOS 14.5, watchOS 7.4, *) { + XCTAssertEqual(getOverrides.assumesHTTP3Capable, true) + XCTAssertNil(postOverrides.assumesHTTP3Capable) + } + #endif } func testDefaultsToStreamingIfSupported() {