Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ import Foundation
baseURL: URL,
requestBody: HTTPBody?,
requestStreamBufferSize: Int,
responseStreamWatermarks: (low: Int, high: Int)
responseStreamWatermarks: (low: Int, high: Int),
requestOverrides: URLSessionTransport.Configuration.RequestOverrides? = nil
) async throws -> (HTTPResponse, HTTPBody?) {
let urlRequest = try URLRequest(request, baseURL: baseURL)
let urlRequest = try URLRequest(request, baseURL: baseURL, requestOverrides: requestOverrides)
let task: URLSessionTask
if requestBody != nil {
task = uploadTask(withStreamedRequest: urlRequest)
Expand Down
107 changes: 99 additions & 8 deletions Sources/OpenAPIURLSession/URLSessionTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ public struct URLSessionTransport: ClientTransport {
/// The URLSession used for performing HTTP operations.
public var session: URLSession

/// A closure that provides request overrides using metadata about an OpenAPI operation.
///
/// 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 (_ metadata: RequestMetadata) -> RequestOverrides)?

/// Creates a new configuration with the provided session.
/// - Parameters:
/// - session: The URLSession used for performing HTTP operations.
Expand All @@ -79,6 +86,68 @@ public struct URLSessionTransport: ClientTransport {
/// - 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.
///
/// - 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.
/// - 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 (_ 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: { metadata in
/// var overrides = URLSessionTransport.Configuration.RequestOverrides.empty
/// #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 {

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.
#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() }
}

/// Specifies the mode in which HTTP request and response bodies are processed.
public struct HTTPBodyProcessingMode: Sendable {
/// Exposing the internal implementation directly.
Expand All @@ -100,8 +169,13 @@ public struct URLSessionTransport: ClientTransport {

var implementation: Implementation

init(session: URLSession = .shared, implementation: Implementation = .platformDefault) {
init(
session: URLSession = .shared,
implementation: Implementation,
requestOverrides: (@Sendable (_ metadata: RequestMetadata) -> RequestOverrides)? = nil
) {
self.session = session
self.requestOverrides = requestOverrides
if case .streaming = implementation {
precondition(Implementation.platformSupportsStreaming, "Streaming not supported on platform")
}
Expand All @@ -127,6 +201,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?(.init(operationID: operationID))
switch configuration.implementation {
case .streaming(let requestBodyStreamBufferSize, let responseBodyStreamWatermarks):
#if canImport(Darwin)
Expand All @@ -138,7 +213,8 @@ public struct URLSessionTransport: ClientTransport {
baseURL: baseURL,
requestBody: requestBody,
requestStreamBufferSize: requestBodyStreamBufferSize,
responseStreamWatermarks: responseBodyStreamWatermarks
responseStreamWatermarks: responseBodyStreamWatermarks,
requestOverrides: requestOverrides
)
#else
throw URLSessionTransportError.streamingNotSupported
Expand All @@ -147,7 +223,8 @@ public struct URLSessionTransport: ClientTransport {
return try await configuration.session.bufferedRequest(
for: request,
baseURL: baseURL,
requestBody: requestBody
requestBody: requestBody,
requestOverrides: requestOverrides
)
}
}
Expand Down Expand Up @@ -203,7 +280,11 @@ extension HTTPResponse {
}

extension URLRequest {
init(_ request: HTTPRequest, baseURL: URL) 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 {
Expand Down Expand Up @@ -234,6 +315,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
}
}

Expand Down Expand Up @@ -313,11 +401,14 @@ 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?,
requestOverrides: URLSessionTransport.Configuration.RequestOverrides? = 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 Task.checkCancellation()

Expand Down
136 changes: 136 additions & 0 deletions Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@ 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.Configuration.RequestOverrides.empty
for value: Bool? in [nil, true, false] {
#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)
#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")!,
Expand Down Expand Up @@ -76,6 +96,14 @@ class URLSessionTransportBufferedTests: XCTestCase {

func testBasicPost() async throws { try await testHTTPBasicPost(transport: transport) }

func testRequestOverridesGet() async throws {
try await testRequestOverrides(transport: transport, withBody: false)
}

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 {
try await testHTTPRedirect(
Expand Down Expand Up @@ -117,6 +145,14 @@ class URLSessionTransportStreamingTests: XCTestCase {

func testBasicPost() async throws { try await testHTTPBasicPost(transport: transport) }

func testRequestOverridesGet() async throws {
try await testRequestOverrides(transport: transport, withBody: false)
}

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 {
try await testHTTPRedirect(
Expand Down Expand Up @@ -218,6 +254,28 @@ func testHTTPRedirect(
}
}

private func testRequestOverrides(transport: URLSessionTransport, withBody: Bool) async throws {
var transport = transport
let called = XCTestExpectation(description: "Request overrides evaluated for the operation")
called.assertForOverFulfill = true
transport.configuration.requestOverrides = { metadata in
XCTAssertEqual(metadata.operationID, "unused")
called.fulfill()
var overrides = URLSessionTransport.Configuration.RequestOverrides.empty
#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 {
try await testHTTPBasicPost(transport: transport)
} else {
try await testHTTPBasicGet(transport: transport)
}
let result = await XCTWaiter.fulfillment(of: [called], timeout: 1)
XCTAssertEqual(result, .completed)
}

func testHTTPBasicGet(transport: any ClientTransport) async throws {
let requestPath = "/hello/world"
let responseBodyMessage = "Hey!"
Expand Down Expand Up @@ -305,6 +363,84 @@ func testHTTPBasicPost(transport: any ClientTransport) async throws {
#endif

class URLSessionTransportPlatformSupportTests: XCTestCase {
func testRequestOverridesDefaults() {
XCTAssertNil(URLSessionTransport.Configuration().requestOverrides)
XCTAssertNil(URLSessionTransport.Configuration(httpBodyProcessingMode: .buffered).requestOverrides)
#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() {
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 (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 (URLSessionTransport.Configuration.RequestMetadata) ->
URLSessionTransport.Configuration.RequestOverrides
)? = { _ in
var overrides = URLSessionTransport.Configuration.RequestOverrides.empty
#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)
let configured = URLSessionTransport.Configuration(
session: session,
httpBodyProcessingMode: .buffered,
requestOverrides: configuredOverrides
)
XCTAssertTrue(configured.session === session)
guard case .buffering = configured.implementation else { return XCTFail("Expected buffering") }
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
#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)
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() {
if URLSessionTransport.Configuration.Implementation.platformSupportsStreaming {
guard case .streaming = URLSessionTransport.Configuration.Implementation.platformDefault else {
Expand Down
Loading