diff --git a/Networking/Sources/Networking/HTTPRequest.swift b/Networking/Sources/Networking/HTTPRequest.swift index 82f2ada431..a4bfa1211a 100644 --- a/Networking/Sources/Networking/HTTPRequest.swift +++ b/Networking/Sources/Networking/HTTPRequest.swift @@ -47,6 +47,21 @@ public struct HTTPRequest: Equatable, Sendable { } public extension HTTPRequest { + /// Initialize a `HTTPRequest` from a `URLRequest`. + /// + /// Returns `nil` if `urlRequest.url` is `nil`. + /// + /// - Parameter urlRequest: The `URLRequest` to convert. + /// + init?(from urlRequest: URLRequest) { + guard let url = urlRequest.url else { return nil } + body = urlRequest.httpBody + headers = urlRequest.allHTTPHeaderFields ?? [:] + method = HTTPMethod(rawValue: urlRequest.httpMethod ?? "GET") + requestID = UUID() + self.url = url + } + /// Initialize a `HTTPRequest` from a `Request` instance. /// /// - Parameters: diff --git a/Networking/Sources/Networking/HTTPService.swift b/Networking/Sources/Networking/HTTPService.swift index af3a8bd436..e12987eedd 100644 --- a/Networking/Sources/Networking/HTTPService.swift +++ b/Networking/Sources/Networking/HTTPService.swift @@ -97,7 +97,8 @@ public final class HTTPService: Sendable { /// - Returns: The `URL` temporary location of the file. /// public func download(from urlRequest: URLRequest) async throws -> URL { - try await client.download(from: urlRequest) + let handledRequest = try await applyRequestHandlers(to: urlRequest) + return try await client.download(from: handledRequest) } /// Downloads the file at `filename` appended to the service's base URL. @@ -110,7 +111,8 @@ public final class HTTPService: Sendable { /// public func download(filename: String) async throws -> URL { let url = baseURL.appendingPathComponent(filename) - return try await client.download(from: URLRequest(url: url)) + let handledRequest = try await applyRequestHandlers(to: URLRequest(url: url)) + return try await client.download(from: handledRequest) } /// Performs a network request. @@ -230,6 +232,25 @@ public final class HTTPService: Sendable { } } + /// Applies any request handlers to a `URLRequest`, returning a modified copy. + /// + /// Converts the `URLRequest` to an `HTTPRequest`, runs it through the handler pipeline + /// (which may inject headers such as SSO cookies), then writes the resulting headers + /// back into the returned `URLRequest`. + /// + /// - Parameter urlRequest: The `URLRequest` to apply request handlers to. + /// - Returns: A copy of `urlRequest` with handler-modified headers applied. + /// + private func applyRequestHandlers(to urlRequest: URLRequest) async throws -> URLRequest { + guard var httpRequest = HTTPRequest(from: urlRequest) else { + return urlRequest + } + try await applyRequestHandlers(&httpRequest) + var modifiedRequest = urlRequest + modifiedRequest.allHTTPHeaderFields = httpRequest.headers + return modifiedRequest + } + /// Applies any response handlers to the response after it's been received. /// /// - Parameters: diff --git a/Networking/Tests/NetworkingTests/HTTPRequestTests.swift b/Networking/Tests/NetworkingTests/HTTPRequestTests.swift index 57d3564010..344c3da7a6 100644 --- a/Networking/Tests/NetworkingTests/HTTPRequestTests.swift +++ b/Networking/Tests/NetworkingTests/HTTPRequestTests.swift @@ -49,6 +49,44 @@ class HTTPRequestTests: XCTestCase { XCTAssertEqual(subject.url, URL(string: "https://example.com/json")!) } + /// `init?(from:)` builds a `HTTPRequest` from a `URLRequest`. + func test_init_fromURLRequest() throws { + var urlRequest = URLRequest(url: URL(string: "https://example.com/json")!) + urlRequest.httpMethod = "POST" + urlRequest.allHTTPHeaderFields = ["Content-Type": "application/json"] + urlRequest.httpBody = "top secret".data(using: .utf8) + + let subject = try XCTUnwrap(HTTPRequest(from: urlRequest)) + + XCTAssertEqual( + try String(data: XCTUnwrap(subject.body), encoding: .utf8), + "top secret", + ) + XCTAssertEqual(subject.headers, ["Content-Type": "application/json"]) + XCTAssertEqual(subject.method, .post) + XCTAssertEqual(subject.url, URL(string: "https://example.com/json")!) + } + + /// `init?(from:)` uses GET and empty headers when the `URLRequest` omits them. + func test_init_fromURLRequest_defaultValues() throws { + let urlRequest = URLRequest(url: URL(string: "https://example.com")!) + + let subject = try XCTUnwrap(HTTPRequest(from: urlRequest)) + + XCTAssertNil(subject.body) + XCTAssertEqual(subject.headers, [:]) + XCTAssertEqual(subject.method, .get) + XCTAssertEqual(subject.url, URL(string: "https://example.com")!) + } + + /// `init?(from:)` returns `nil` when the `URLRequest` has no URL. + func test_init_fromURLRequest_nilURL() { + var urlRequest = URLRequest(url: URL(string: "https://example.com")!) + urlRequest.url = nil + + XCTAssertNil(HTTPRequest(from: urlRequest)) + } + /// `init(request:baseURL)` builds a `HTTPRequest` from a `Request` object. func test_init_request() throws { let subject = try HTTPRequest( diff --git a/Networking/Tests/NetworkingTests/HTTPServiceTests.swift b/Networking/Tests/NetworkingTests/HTTPServiceTests.swift index 4928543b57..85b555bba7 100644 --- a/Networking/Tests/NetworkingTests/HTTPServiceTests.swift +++ b/Networking/Tests/NetworkingTests/HTTPServiceTests.swift @@ -303,6 +303,52 @@ class HTTPServiceTests: XCTestCase { XCTAssertEqual(error as? TestError, .invalidResponse) } } +} + +// MARK: - Download Tests + +extension HTTPServiceTests { + /// `download(from:)` applies request handlers to the URL request before downloading. + func test_download_fromURLRequest_appliesRequestHandlers() async throws { + let requestHandler = TestRequestHandler { request in + request.headers["Cookie"] = "sso=test-token" + } + subject = HTTPService( + baseURL: URL(string: "https://example.com")!, + client: client, + requestHandlers: [requestHandler], + ) + + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test.json") + try "{}".write(to: tempFile, atomically: true, encoding: .utf8) + client.downloadResults = [.success(tempFile)] + + _ = try await subject.download(from: URLRequest(url: URL(string: "https://example.com/file.zip")!)) + + let downloadRequest = try XCTUnwrap(client.downloadRequests.last) + XCTAssertEqual(downloadRequest.allHTTPHeaderFields?["Cookie"], "sso=test-token") + } + + /// `download(filename:)` applies request handlers to the URL request before downloading. + func test_download_filename_appliesRequestHandlers() async throws { + let requestHandler = TestRequestHandler { request in + request.headers["Cookie"] = "sso=test-token" + } + subject = HTTPService( + baseURL: URL(string: "https://example.com")!, + client: client, + requestHandlers: [requestHandler], + ) + + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test.json") + try "{}".write(to: tempFile, atomically: true, encoding: .utf8) + client.downloadResults = [.success(tempFile)] + + _ = try await subject.download(filename: "manifest.json") + + let downloadRequest = try XCTUnwrap(client.downloadRequests.last) + XCTAssertEqual(downloadRequest.allHTTPHeaderFields?["Cookie"], "sso=test-token") + } /// `download(filename:)` appends the filename to the base URL and calls the client. func test_download_filename() async throws { @@ -321,37 +367,3 @@ class HTTPServiceTests: XCTestCase { } private struct RequestError: Error {} - -/// A `ResponseHandler` that captures the `retryWith` closure passed to it for inspection in tests. -@MainActor -private class CapturingRetryWithResponseHandler: ResponseHandler { - var onHandle: (((HTTPRequest) async throws -> HTTPResponse)?) -> Void - - init(onHandle: @escaping (((HTTPRequest) async throws -> HTTPResponse)?) -> Void) { - self.onHandle = onHandle - } - - func handle( - _ response: inout HTTPResponse, - for request: HTTPRequest, - retryWith: ((HTTPRequest) async throws -> HTTPResponse)?, - ) async throws -> HTTPResponse { - onHandle(retryWith) - return response - } -} - -/// A `ResponseHandler` that follows a 302 redirect by calling `retryWith` with the original request. -@MainActor -private class RedirectFollowingResponseHandler: ResponseHandler { - func handle( - _ response: inout HTTPResponse, - for request: HTTPRequest, - retryWith: ((HTTPRequest) async throws -> HTTPResponse)?, - ) async throws -> HTTPResponse { - guard response.statusCode == 302, let retryWith else { - return response - } - return try await retryWith(request) - } -} diff --git a/Networking/Tests/NetworkingTests/Support/CapturingRetryWithResponseHandler.swift b/Networking/Tests/NetworkingTests/Support/CapturingRetryWithResponseHandler.swift new file mode 100644 index 0000000000..3541dbbf92 --- /dev/null +++ b/Networking/Tests/NetworkingTests/Support/CapturingRetryWithResponseHandler.swift @@ -0,0 +1,20 @@ +import Networking + +/// A `ResponseHandler` that captures the `retryWith` closure passed to it for inspection in tests. +@MainActor +class CapturingRetryWithResponseHandler: ResponseHandler { + var onHandle: (((HTTPRequest) async throws -> HTTPResponse)?) -> Void + + init(onHandle: @escaping (((HTTPRequest) async throws -> HTTPResponse)?) -> Void) { + self.onHandle = onHandle + } + + func handle( + _ response: inout HTTPResponse, + for request: HTTPRequest, + retryWith: ((HTTPRequest) async throws -> HTTPResponse)?, + ) async throws -> HTTPResponse { + onHandle(retryWith) + return response + } +} diff --git a/Networking/Tests/NetworkingTests/Support/RedirectFollowingResponseHandler.swift b/Networking/Tests/NetworkingTests/Support/RedirectFollowingResponseHandler.swift new file mode 100644 index 0000000000..00ba90fca3 --- /dev/null +++ b/Networking/Tests/NetworkingTests/Support/RedirectFollowingResponseHandler.swift @@ -0,0 +1,16 @@ +import Networking + +/// A `ResponseHandler` that follows a 302 redirect by calling `retryWith` with the original request. +@MainActor +class RedirectFollowingResponseHandler: ResponseHandler { + func handle( + _ response: inout HTTPResponse, + for request: HTTPRequest, + retryWith: ((HTTPRequest) async throws -> HTTPResponse)?, + ) async throws -> HTTPResponse { + guard response.statusCode == 302, let retryWith else { + return response + } + return try await retryWith(request) + } +}