Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Networking/Sources/Networking/HTTPRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 23 additions & 2 deletions Networking/Sources/Networking/HTTPService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions Networking/Tests/NetworkingTests/HTTPRequestTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
80 changes: 46 additions & 34 deletions Networking/Tests/NetworkingTests/HTTPServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading