diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift index a1047fa85..38982fdc7 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift @@ -46,7 +46,7 @@ extension HTTPClient { request, deadline: deadline, logger: logger ?? Self.loggingDisabled, - redirectState: RedirectState(self.configuration.redirectConfiguration.mode, initialURL: request.url) + redirectMode: self.configuration.redirectConfiguration.mode ) } } @@ -88,10 +88,11 @@ extension HTTPClient { _ request: HTTPClientRequest, deadline: NIODeadline, logger: Logger, - redirectState: RedirectState? + redirectMode: HTTPClient.Configuration.RedirectConfiguration.Mode ) async throws -> HTTPClientResponse { var currentRequest = request - var currentRedirectState = redirectState + var currentRedirectState = RedirectState(redirectMode, initialURL: request.url) + var customRedirectCount = 0 var history: [HTTPClientRequestResponse] = [] // this loop is there to follow potential redirects @@ -122,39 +123,100 @@ extension HTTPClient { return response }() - guard var redirectState = currentRedirectState else { - // a `nil` redirectState means we should not follow redirects + switch redirectMode { + case .disallow: return response - } - guard - let redirectURL = response.headers.extractRedirectTarget( + case .follow: + guard var redirectState = currentRedirectState else { + // a `nil` redirectState means we should not follow redirects + return response + } + + guard + let redirectURL = response.headers.extractRedirectTarget( + status: response.status, + originalURL: preparedRequest.url, + originalScheme: preparedRequest.poolKey.scheme + ) + else { + // response does not want a redirect + return response + } + + // validate that we do not exceed any limits or are running circles + try redirectState.redirect(to: redirectURL.absoluteString) + currentRedirectState = redirectState + + let newRequest = currentRequest.followingRedirect( + from: preparedRequest.url, + to: redirectURL, status: response.status, - originalURL: preparedRequest.url, - originalScheme: preparedRequest.poolKey.scheme + config: redirectState.config ) - else { - // response does not want a redirect - return response - } - // validate that we do not exceed any limits or are running circles - try redirectState.redirect(to: redirectURL.absoluteString) - currentRedirectState = redirectState + guard newRequest.body.canBeConsumedMultipleTimes else { + // we already send the request body and it cannot be send again + return response + } - let newRequest = currentRequest.followingRedirect( - from: preparedRequest.url, - to: redirectURL, - status: response.status, - config: redirectState.config - ) + currentRequest = newRequest - guard newRequest.body.canBeConsumedMultipleTimes else { - // we already send the request body and it cannot be send again - return response - } + case .strategy(let anyStrategy): + let strategy = anyStrategy as! any HTTPClientRedirectStrategy + guard + let redirectURL = response.headers.extractRedirectTarget( + status: response.status, + originalURL: preparedRequest.url, + originalScheme: preparedRequest.poolKey.scheme + ) + else { + // response does not want a redirect + return response + } + + // Pre-build the request the same way `.follow` would, applying the standard + // method/header rewrite rules, so the strategy only needs to make further + // adjustments rather than reimplement those rules itself. `max`/`allowCycles` + // are irrelevant here: only the `retainHTTPMethodAndBodyOn30{1,2}` flags feed + // into this transformation, and there's no built-in limit in `.strategy` mode. + let candidateRequest = currentRequest.followingRedirect( + from: preparedRequest.url, + to: redirectURL, + status: response.status, + config: .init( + max: 0, + allowCycles: true, + retainHTTPMethodAndBodyOn301: false, + retainHTTPMethodAndBodyOn302: false + ) + ) - currentRequest = newRequest + let context = HTTPClientRedirectContext( + redirectRequest: candidateRequest, + response: HTTPResponseHead( + version: response.version, + status: response.status, + headers: response.headers + ), + history: history, + redirectCount: customRedirectCount + ) + + switch try strategy.redirectDecision(for: context) { + case .doNotFollow: + return response + + case .follow(let newRequest): + guard newRequest.body.canBeConsumedMultipleTimes else { + // we already send the request body and it cannot be send again + return response + } + + customRedirectCount += 1 + currentRequest = newRequest + } + } } } diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRedirectStrategy.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRedirectStrategy.swift new file mode 100644 index 000000000..508b2ff79 --- /dev/null +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRedirectStrategy.swift @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOHTTP1 + +/// A pluggable strategy for deciding whether — and how — to follow HTTP redirects, used via +/// ``HTTPClient/Configuration/RedirectConfiguration/strategy(_:)``. +/// +/// Unlike `.disallow`/`.follow(max:allowCycles:)`, a strategy gets a chance to inspect every +/// redirect-eligible response before it's followed: adjust the outgoing request, refuse the redirect +/// outright, or fail the whole request with a custom error. +/// +/// A single strategy instance is stored on ``HTTPClient/Configuration`` and reused for every request +/// that client makes, including concurrently — if your strategy holds mutable state (e.g. an audit +/// log, a shared allow-list), synchronize it yourself (an `actor`, or a class using a lock). +/// Per-request state doesn't need that: ``HTTPClientRedirectContext/history`` already carries +/// everything tracked so far for the *current* logical request, so most policies (host allow-listing, +/// loop bounds, auditing) can be implemented statelessly by reading it fresh on each call. +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +public protocol HTTPClientRedirectStrategy: Sendable { + /// Decide whether — and how — to follow a redirect. + /// + /// - Parameter context: Everything known about the redirect so far. See + /// ``HTTPClientRedirectContext``. + /// - Returns: Whether — and with what request — to follow the redirect. + /// - Throws: To fail the whole `execute(...)` call with a custom error instead of following the + /// redirect or returning the response that triggered it. + func redirectDecision(for context: HTTPClientRedirectContext) throws -> HTTPClientRedirectDecision +} + +/// Everything a ``HTTPClientRedirectStrategy`` is handed to decide whether — and how — to follow one +/// redirect. +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +public struct HTTPClientRedirectContext: Sendable { + /// The request that would be sent to follow the redirect. It has already gone through the same + /// method/header rewrite rules `.follow` would apply (converting `POST` to `GET` on a 303, + /// stripping `Authorization`/`Cookie`/`Origin`/`Proxy-Authorization` on cross-origin redirects) — + /// you only need to make further adjustments, not reimplement those rules from scratch. + public var redirectRequest: HTTPClientRequest + + /// The head of the response that triggered the redirect. + public var response: HTTPResponseHead + + /// Every request/response pair sent so far for this logical request, oldest first, including the + /// one that produced ``response``. This is the same data that ends up in + /// ``HTTPClientResponse/history`` on the final response. + public var history: [HTTPClientRequestResponse] + + /// How many redirects have already been followed for this logical request (equivalently, + /// `history.count - 1`). There is no built-in limit for `.strategy`/`.custom` mode — enforce your + /// own policy (e.g. refusing past a maximum count) to avoid infinite redirect loops. + public var redirectCount: Int + + public init( + redirectRequest: HTTPClientRequest, + response: HTTPResponseHead, + history: [HTTPClientRequestResponse], + redirectCount: Int + ) { + self.redirectRequest = redirectRequest + self.response = response + self.history = history + self.redirectCount = redirectCount + } +} + +/// The result of a ``HTTPClientRedirectStrategy`` deciding whether — and how — to follow a redirect. +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +public enum HTTPClientRedirectDecision: Sendable { + /// Follow the redirect using the given request. + case follow(HTTPClientRequest) + /// Do not follow the redirect; the response that triggered it is returned as-is. + case doNotFollow +} + +/// Adapts a closure to ``HTTPClientRedirectStrategy``, backing +/// ``HTTPClient/Configuration/RedirectConfiguration/custom(_:)``. +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +struct ClosureRedirectStrategy: HTTPClientRedirectStrategy { + let handler: @Sendable (HTTPClientRedirectContext) throws -> HTTPClientRedirectDecision + + func redirectDecision(for context: HTTPClientRedirectContext) throws -> HTTPClientRedirectDecision { + try self.handler(context) + } +} diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index cc8792497..301973538 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -759,6 +759,23 @@ public final class HTTPClient: Sendable { ] ) + if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *), + case .strategy = self.configuration.redirectConfiguration.mode + { + // `.strategy`/`.custom` redirect handlers operate on `HTTPClientRequest`/`HTTPClientResponse` + // and are only wired up for the Swift Concurrency `execute(_:deadline:logger:)` family of APIs. + logger.debug( + "`.strategy` redirect configuration is not supported by the delegate-based execute API, failing request" + ) + return Task.failedTask( + eventLoop: taskEL, + error: HTTPClientError.invalidRedirectConfiguration, + logger: logger, + tracing: tracing, + makeOrGetFileIOThreadPool: self.makeOrGetFileIOThreadPool + ) + } + let failedTask: Task? = self.state.withLockedValue { state -> (Task?) in switch state { case .upAndRunning: @@ -1312,11 +1329,18 @@ extension HTTPClient.Configuration { /// Specifies redirect processing settings. public struct RedirectConfiguration: Sendable { - enum Mode: Hashable { + enum Mode { /// Redirects are not followed. case disallow /// Redirects are followed with a specified limit. case follow(FollowConfiguration) + /// Redirects are handed to a pluggable ``HTTPClientRedirectStrategy``. + /// + /// Stored as `any Sendable` (erasure trick so this case doesn't need to be marked + /// `@available`, which Swift disallows on enum cases with associated values) — always an + /// `any HTTPClientRedirectStrategy` underneath, since `.strategy(_:)`/`.custom(_:)` are the + /// only way to construct one. + case strategy(any Sendable) } /// Configuration for following redirects. @@ -1397,6 +1421,29 @@ extension HTTPClient.Configuration { public static func follow(configuration: FollowConfiguration) -> RedirectConfiguration { .init(configuration: .follow(configuration)) } + + /// Redirects are handed to a pluggable strategy, which decides whether and how to follow each + /// one. See ``HTTPClientRedirectStrategy``. + /// + /// - warning: There is no built-in redirect-count or cycle limit for this mode — use the + /// `redirectCount`/`history` passed to the strategy to enforce your own policy. + /// - note: Only supported by the Swift Concurrency `execute(_:deadline:logger:)` family of APIs. + /// Using `.strategy`/`.custom` with the delegate-based `execute(request:delegate:...)` API + /// fails with ``HTTPClientError/invalidRedirectConfiguration``. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public static func strategy(_ strategy: any HTTPClientRedirectStrategy) -> RedirectConfiguration { + .init(configuration: .strategy(strategy)) + } + + /// Convenience over ``strategy(_:)`` for a policy that doesn't need its own type: redirects are + /// handed to `handler`, which decides whether and how to follow each one. See + /// ``HTTPClientRedirectContext`` for what `handler` receives. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public static func custom( + _ handler: @escaping @Sendable (HTTPClientRedirectContext) throws -> HTTPClientRedirectDecision + ) -> RedirectConfiguration { + .strategy(ClosureRedirectStrategy(handler: handler)) + } } /// Connection pool configuration. diff --git a/Sources/AsyncHTTPClient/RedirectState.swift b/Sources/AsyncHTTPClient/RedirectState.swift index 9665c03d4..466b4af6a 100644 --- a/Sources/AsyncHTTPClient/RedirectState.swift +++ b/Sources/AsyncHTTPClient/RedirectState.swift @@ -22,6 +22,37 @@ import struct Foundation.URL typealias RedirectMode = HTTPClient.Configuration.RedirectConfiguration.Mode +// `Mode` can't derive `Equatable`/`Hashable` because `.strategy` carries an existential. `.strategy` +// values have no meaningful notion of equality, so — like `NaN` — a `.strategy` value is never equal +// to any other value, including another `.strategy`; this is consistent (if vacuously so) with the +// `Hashable` requirement that equal values hash equally. +extension HTTPClient.Configuration.RedirectConfiguration.Mode: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.disallow, .disallow): + return true + case (.follow(let lhsConfig), .follow(let rhsConfig)): + return lhsConfig == rhsConfig + default: + return false + } + } +} + +extension HTTPClient.Configuration.RedirectConfiguration.Mode: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .disallow: + hasher.combine(0) + case .follow(let config): + hasher.combine(1) + hasher.combine(config) + case .strategy: + hasher.combine(2) + } + } +} + struct RedirectState { var config: HTTPClient.Configuration.RedirectConfiguration.FollowConfiguration @@ -42,6 +73,10 @@ extension RedirectState { return nil case .follow(let config): self.init(config: config, visited: [initialURL]) + case .strategy: + // `.strategy` redirects are handled entirely by the caller-supplied strategy; there is no + // count/cycle state for `RedirectState` to track. + return nil } } } diff --git a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift index 78dec4296..f7d627d02 100644 --- a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift +++ b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import Logging +import NIOConcurrencyHelpers import NIOCore import NIOFoundationCompat import NIOHTTP1 @@ -768,6 +769,177 @@ final class AsyncAwaitEndToEndTests: XCTestCase { } } + // MARK: - Pluggable redirect strategies + + func testCustomRedirectHandlerCanRewriteRedirectRequest() { + XCTAsyncTest { + let bin = HTTPBin(.http1_1(compress: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let port = bin.port + var config = HTTPClient.Configuration() + config.redirectConfiguration = .custom { context in + XCTAssertEqual(context.response.status, .found) + XCTAssertEqual(context.redirectCount, 0) + XCTAssertEqual(context.history.count, 1) + // The candidate request has already gone through the standard rewrite rules: it + // should already point at the `Location` from the /redirect/302 response. + XCTAssertEqual(context.redirectRequest.url, "http://localhost:\(port)/ok") + + var rewritten = context.redirectRequest + rewritten.url = "http://localhost:\(port)/echo-uri" + return .follow(rewritten) + } + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let request = HTTPClientRequest(url: "http://localhost:\(port)/redirect/302") + guard + let response = await XCTAssertNoThrowWithResult( + try await client.execute(request, deadline: .now() + .seconds(10)) + ) + else { return } + + XCTAssertEqual(response.status, .ok) + XCTAssertEqual(response.headers.first(name: "X-Calling-URI"), "/echo-uri") + XCTAssertEqual( + response.history.map(\.request.url), + ["http://localhost:\(port)/redirect/302", "http://localhost:\(port)/echo-uri"] + ) + } + } + + func testCustomRedirectHandlerCanRefuseRedirect() { + XCTAsyncTest { + let bin = HTTPBin(.http1_1(compress: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + var config = HTTPClient.Configuration() + config.redirectConfiguration = .custom { _ in .doNotFollow } + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let request = HTTPClientRequest(url: "http://localhost:\(bin.port)/redirect/302") + guard + let response = await XCTAssertNoThrowWithResult( + try await client.execute(request, deadline: .now() + .seconds(10)) + ) + else { return } + + // The handler refused the redirect, so the 302 itself is the final response. + XCTAssertEqual(response.status, .found) + XCTAssertEqual(response.history.count, 1) + } + } + + func testCustomRedirectHandlerReceivesIncreasingRedirectCountAndCanBoundLoops() { + XCTAsyncTest { + let bin = HTTPBin(.http1_1(compress: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let observedCounts = NIOLockedValueBox<[Int]>([]) + var config = HTTPClient.Configuration() + config.redirectConfiguration = .custom { context in + XCTAssertEqual(context.history.count, context.redirectCount + 1) + observedCounts.withLockedValue { $0.append(context.redirectCount) } + guard context.redirectCount < 3 else { + return .doNotFollow + } + return .follow(context.redirectRequest) + } + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + // /redirect/infinite1 <-> /redirect/infinite2 bounce forever. `.custom`/`.strategy` mode + // has no built-in redirect limit (unlike `.follow`), so this exercises the handler + // enforcing its own bound using the `redirectCount` it's handed. + let request = HTTPClientRequest(url: "http://localhost:\(bin.port)/redirect/infinite1") + guard + let response = await XCTAssertNoThrowWithResult( + try await client.execute(request, deadline: .now() + .seconds(10)) + ) + else { return } + + XCTAssertEqual(response.status, .found) + XCTAssertEqual(observedCounts.withLockedValue { $0 }, [0, 1, 2, 3]) + XCTAssertEqual(response.history.count, 4) + } + } + + /// A real `HTTPClientRedirectStrategy` conformance (not a closure), proving strategies are + /// genuinely pluggable types — and using `history` (not just a count) to detect that a redirect + /// target has already been visited, the way `.follow(allowCycles: false)` does internally. + private struct VisitedURLCycleDetectingStrategy: HTTPClientRedirectStrategy { + func redirectDecision(for context: HTTPClientRedirectContext) throws -> HTTPClientRedirectDecision { + let visited = Set(context.history.map(\.request.url)) + guard !visited.contains(context.redirectRequest.url) else { + return .doNotFollow + } + return .follow(context.redirectRequest) + } + } + + func testRedirectStrategyTypeDetectsCyclesUsingHistory() { + XCTAsyncTest { + let bin = HTTPBin(.http1_1(compress: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + var config = HTTPClient.Configuration() + config.redirectConfiguration = .strategy(VisitedURLCycleDetectingStrategy()) + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + // infinite1 -> infinite2 -> infinite1 (already visited, refused). + let request = HTTPClientRequest(url: "http://localhost:\(bin.port)/redirect/infinite1") + guard + let response = await XCTAssertNoThrowWithResult( + try await client.execute(request, deadline: .now() + .seconds(10)) + ) + else { return } + + XCTAssertEqual(response.status, .found) + XCTAssertEqual( + response.history.map(\.request.url), + [ + "http://localhost:\(bin.port)/redirect/infinite1", + "http://localhost:\(bin.port)/redirect/infinite2", + ] + ) + } + } + + private struct RedirectRefusedError: Error, Equatable {} + + private struct ThrowingRedirectStrategy: HTTPClientRedirectStrategy { + func redirectDecision(for context: HTTPClientRedirectContext) throws -> HTTPClientRedirectDecision { + throw RedirectRefusedError() + } + } + + func testRedirectStrategyCanThrowToFailTheRequest() { + XCTAsyncTest { + let bin = HTTPBin(.http1_1(compress: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + var config = HTTPClient.Configuration() + config.redirectConfiguration = .strategy(ThrowingRedirectStrategy()) + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let request = HTTPClientRequest(url: "http://localhost:\(bin.port)/redirect/302") + await XCTAssertThrowsError( + try await client.execute(request, deadline: .now() + .seconds(10)) + ) { + XCTAssertEqual($0 as? RedirectRefusedError, RedirectRefusedError()) + } + } + } + func testShutdown() { XCTAsyncTest { let client = makeDefaultHTTPClient() diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index 75371d437..5ae14994e 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -448,6 +448,24 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { XCTAssertEqual("1234", data.data) } + func testCustomRedirectConfigurationFailsWithDelegateBasedExecute() throws { + // `.custom` redirect handlers operate on `HTTPClientRequest`/`HTTPClientResponse` and are only + // wired up for the Swift Concurrency `execute(_:deadline:logger:)` family of APIs; the + // delegate-based `execute(request:delegate:...)` API (exercised here via `.get`) should fail + // fast rather than silently ignore the configured handler. + let localClient = HTTPClient( + eventLoopGroupProvider: .shared(self.clientGroup), + configuration: HTTPClient.Configuration( + redirectConfiguration: .custom { _ in .doNotFollow } + ) + ) + defer { XCTAssertNoThrow(try localClient.syncShutdown()) } + + XCTAssertThrowsError(try localClient.get(url: self.defaultHTTPBinURLPrefix + "ok").wait()) { + XCTAssertEqual($0 as? HTTPClientError, .invalidRedirectConfiguration) + } + } + func testHttpRedirect() throws { let httpsBin = HTTPBin(.http1_1(ssl: true)) let localClient = HTTPClient( diff --git a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift index e80d49ed8..f2f1f8a98 100644 --- a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift +++ b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift @@ -67,7 +67,7 @@ struct HTTPClientConfigurationPropsTests { #expect(follow.allowCycles) #expect(follow.retainHTTPMethodAndBodyOn301) #expect(follow.retainHTTPMethodAndBodyOn302) - case .disallow: + case .disallow, .strategy: Issue.record("Unexpected value") } @@ -130,7 +130,7 @@ struct HTTPClientConfigurationPropsTests { switch config.redirectConfiguration.mode { case .disallow: break - case .follow: + case .follow, .strategy: Issue.record("Unexpected value") } }