diff --git a/Sources/HTTPNetworking/Plugins/Adaptors/HeadersAdaptor.swift b/Sources/HTTPNetworking/Plugins/Adaptors/HeadersAdaptor.swift new file mode 100644 index 0000000..5856d29 --- /dev/null +++ b/Sources/HTTPNetworking/Plugins/Adaptors/HeadersAdaptor.swift @@ -0,0 +1,74 @@ +import Foundation + +/// An ``HTTPRequestAdaptor`` that can be used to append HTTP headers to a request before it is sent out over the network. +public struct HeadersAdaptor: HTTPRequestAdaptor { + + // MARK: CustomCollisionStrategyHandler + + public typealias CustomCollisionStrategyHandler = (_ field: String, _ oldValue: String, _ newValue: String) -> String + + // MARK: CollisionStrategy + + /// A strategy used to handle occurences of header field collisions between the existing request headers and the new being added. + public enum CollisionStrategy { + /// A strategy where collisions result in the older value taking precedence. + case useOlderValue + /// A strategy where collisions result in the newer value taking precedence. + case useNewerValue + /// A strategy where collisions result in the newer value being appended to the older value using a comma as the delimiter + case useBothValues + /// A strategy where the handler receives the field and both the old and new values. The value returned by the handler will be used as the field's value. + case custom(CustomCollisionStrategyHandler) + } + + // MARK: Properties + + let headers: [String: String] + let strategy: CollisionStrategy + + // MARK: Initializers + + /// Creates a ``HeadersAdaptor`` from the provided HTTP headers. + /// + /// - Parameters: + /// - headers: The HTTP headers to append to incoming requests. + /// - strategy: The strategy to use when the adaptor encounters a field collision. + public init(headers: [String: String], strategy: CollisionStrategy) { + self.headers = headers + self.strategy = strategy + } + + // MARK: HTTPRequestAdaptor + + public func adapt(_ request: URLRequest, for session: URLSession) async throws -> URLRequest { + var request = request + for (key, newValue) in headers { + if let oldValue = request.value(forHTTPHeaderField: key) { + switch strategy { + case .useOlderValue: + continue + case .useNewerValue: + request.setValue(newValue, forHTTPHeaderField: key) + case .useBothValues: + request.addValue(newValue, forHTTPHeaderField: key) + case .custom(let handler): + request.setValue(handler(key, oldValue, newValue), forHTTPHeaderField: key) + } + } else { + request.setValue(newValue, forHTTPHeaderField: key) + } + } + + return request + } +} + +// MARK: - HTTPRequest + HeadersAdaptor + +extension HTTPRequest { + /// Applies a ``HeadersAdaptor`` that appends the provided headers to the request. + @discardableResult + public func adapt(headers: [String: String], strategy: HeadersAdaptor.CollisionStrategy = .useNewerValue) -> Self { + adapt(with: HeadersAdaptor(headers: headers, strategy: strategy)) + } +} diff --git a/Sources/HTTPNetworking/Plugins/Adaptors/ParametersAdaptor.swift b/Sources/HTTPNetworking/Plugins/Adaptors/ParametersAdaptor.swift index 1ff7b17..a287bd6 100644 --- a/Sources/HTTPNetworking/Plugins/Adaptors/ParametersAdaptor.swift +++ b/Sources/HTTPNetworking/Plugins/Adaptors/ParametersAdaptor.swift @@ -31,7 +31,7 @@ public struct ParametersAdaptor: HTTPRequestAdaptor { } } -// MARK: - HTTPRequest + Adaptor +// MARK: - HTTPRequest + ParametersAdaptor extension HTTPRequest { /// Applies a ``ParametersAdaptor`` that appends the provided query parameters to the request. diff --git a/Tests/HTTPNetworkingTests/Plugins/Adaptors/HeadersAdaptorTests.swift b/Tests/HTTPNetworkingTests/Plugins/Adaptors/HeadersAdaptorTests.swift new file mode 100644 index 0000000..c0f0b75 --- /dev/null +++ b/Tests/HTTPNetworkingTests/Plugins/Adaptors/HeadersAdaptorTests.swift @@ -0,0 +1,114 @@ +@testable import HTTPNetworking +import XCTest + +class HeadersAdaptorTests: XCTestCase { + func test_headersAdaptor_withUseOlderValueStrategy_returnsExpectedParameters() async throws { + let headerAdaptorOne = HeadersAdaptor(headers: [ + "HEADER-ONE": "VALUE-ONE-OLD", + "HEADER-TWO": "VALUE-TWO-OLD", + ], strategy: .useOlderValue) + + let headerAdaptorTwo = HeadersAdaptor(headers: [ + "HEADER-TWO": "VALUE-TWO-NEW", + "HEADER-THREE": "VALUE-THREE-NEW", + ], strategy: .useOlderValue) + + let adaptor = ZipAdaptor([headerAdaptorOne, headerAdaptorTwo]) + let request = try await adaptor.adapt(URLRequest(url: URL(string: "https://api.com")!), for: .shared) + + XCTAssertEqual(request.allHTTPHeaderFields, [ + "HEADER-ONE": "VALUE-ONE-OLD", + "HEADER-TWO": "VALUE-TWO-OLD", + "HEADER-THREE": "VALUE-THREE-NEW", + ]) + } + + func test_headersAdaptor_withUseNewerValueStrategy_returnsExpectedParameters() async throws { + let headerAdaptorOne = HeadersAdaptor(headers: [ + "HEADER-ONE": "VALUE-ONE-OLD", + "HEADER-TWO": "VALUE-TWO-OLD", + ], strategy: .useNewerValue) + + let headerAdaptorTwo = HeadersAdaptor(headers: [ + "HEADER-TWO": "VALUE-TWO-NEW", + "HEADER-THREE": "VALUE-THREE-NEW", + ], strategy: .useNewerValue) + + let adaptor = ZipAdaptor([headerAdaptorOne, headerAdaptorTwo]) + let request = try await adaptor.adapt(URLRequest(url: URL(string: "https://api.com")!), for: .shared) + + XCTAssertEqual(request.allHTTPHeaderFields, [ + "HEADER-ONE": "VALUE-ONE-OLD", + "HEADER-TWO": "VALUE-TWO-NEW", + "HEADER-THREE": "VALUE-THREE-NEW", + ]) + } + + func test_headersAdaptor_withUseBothValuesStrategy_returnsExpectedParameters() async throws { + let headerAdaptorOne = HeadersAdaptor(headers: [ + "HEADER-ONE": "VALUE-ONE-OLD", + "HEADER-TWO": "VALUE-TWO-OLD", + ], strategy: .useBothValues) + + let headerAdaptorTwo = HeadersAdaptor(headers: [ + "HEADER-TWO": "VALUE-TWO-NEW", + "HEADER-THREE": "VALUE-THREE-NEW", + ], strategy: .useBothValues) + + let adaptor = ZipAdaptor([headerAdaptorOne, headerAdaptorTwo]) + let request = try await adaptor.adapt(URLRequest(url: URL(string: "https://api.com")!), for: .shared) + + XCTAssertEqual(request.allHTTPHeaderFields, [ + "HEADER-ONE": "VALUE-ONE-OLD", + "HEADER-TWO": "VALUE-TWO-OLD,VALUE-TWO-NEW", + "HEADER-THREE": "VALUE-THREE-NEW", + ]) + } + + func test_headersAdaptor_withUseCustomStrategy_callsHandlerAndReturnsExpectedParameters() async throws { + let expectation = expectation(description: "Expected handler to be called") + let headerAdaptorOne = HeadersAdaptor(headers: [ + "HEADER-ONE": "VALUE-ONE-OLD", + "HEADER-TWO": "VALUE-TWO-OLD", + ], strategy: .useBothValues) + + let headerAdaptorTwo = HeadersAdaptor(headers: [ + "HEADER-TWO": "VALUE-TWO-NEW", + "HEADER-THREE": "VALUE-THREE-NEW", + ], strategy: .custom({ _, _, newValue in + expectation.fulfill() + return newValue + })) + + let adaptor = ZipAdaptor([headerAdaptorOne, headerAdaptorTwo]) + let request = try await adaptor.adapt(URLRequest(url: URL(string: "https://api.com")!), for: .shared) + + XCTAssertEqual(request.allHTTPHeaderFields, [ + "HEADER-ONE": "VALUE-ONE-OLD", + "HEADER-TWO": "VALUE-TWO-NEW", + "HEADER-THREE": "VALUE-THREE-NEW", + ]) + + await fulfillment(of: [expectation]) + } + + func test_request_adaptorConvenience_isAddedToRequestAdaptors() async throws { + let url = URL(string: "https://api.com")! + let client = HTTPClient() + let request = client.request(for: .get, to: url, expecting: String.self) + + let headers: [String: String] = [ + "HEADER-ONE": "VALUE-ONE", + "HEADER-TWO": "VALUE-TWO", + ] + + request.adapt(headers: headers) + + guard let adaptor = request.adaptors.first as? HeadersAdaptor else { + XCTFail("Expected request to container HeadersAdaptor.") + return + } + + XCTAssertEqual(adaptor.headers, headers) + } +}