diff --git a/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift b/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift index 4cd091f..057cc8b 100644 --- a/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift +++ b/Sources/OpenAPIURLSession/URLSessionBidirectionalStreaming/URLSession+Extensions.swift @@ -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) diff --git a/Sources/OpenAPIURLSession/URLSessionTransport.swift b/Sources/OpenAPIURLSession/URLSessionTransport.swift index 65dd92e..92f4d9a 100644 --- a/Sources/OpenAPIURLSession/URLSessionTransport.swift +++ b/Sources/OpenAPIURLSession/URLSessionTransport.swift @@ -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. @@ -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. @@ -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") } @@ -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) @@ -138,7 +213,8 @@ public struct URLSessionTransport: ClientTransport { baseURL: baseURL, requestBody: requestBody, requestStreamBufferSize: requestBodyStreamBufferSize, - responseStreamWatermarks: responseBodyStreamWatermarks + responseStreamWatermarks: responseBodyStreamWatermarks, + requestOverrides: requestOverrides ) #else throw URLSessionTransportError.streamingNotSupported @@ -147,7 +223,8 @@ public struct URLSessionTransport: ClientTransport { return try await configuration.session.bufferedRequest( for: request, baseURL: baseURL, - requestBody: requestBody + requestBody: requestBody, + requestOverrides: requestOverrides ) } } @@ -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 { @@ -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 } } @@ -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() diff --git a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift index ffacd53..4beb080 100644 --- a/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift +++ b/Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift @@ -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")!, @@ -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( @@ -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( @@ -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!" @@ -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 {