Skip to content
Merged
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
11 changes: 1 addition & 10 deletions Sources/Networking/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ extension FileManager {
}

extension URLRequest {
init(url: URL, requestType: Networking.RequestType, path _: String, contentType: String?, responseType: Networking.ResponseType, authorizationHeaderValue: String?, token: String?, authorizationHeaderKey: String, headerFields: [String: String]?) {
init(url: URL, requestType: Networking.RequestType, contentType: String?, responseType: Networking.ResponseType, authorizationHeaderValue: String?, token: String?, authorizationHeaderKey: String, headerFields: [String: String]?) {
self = URLRequest(url: url)
httpMethod = requestType.rawValue

Expand All @@ -87,15 +87,6 @@ extension URLRequest {
}
}

extension URL {
func getData() -> Data {
let path = self.path
guard let data = FileManager.default.contents(atPath: path) else { fatalError("Couldn't get image in destination url: \(self)") }

return data
}
}

extension HTTPURLResponse {
convenience init(url: URL, headerFields: [String : String]? = nil, statusCode: Int) {
self.init(url: url, statusCode: statusCode, httpVersion: nil, headerFields: headerFields)!
Expand Down
27 changes: 9 additions & 18 deletions Sources/Networking/Networking+New.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ extension Networking {

do {
if let fakeRequest = try FakeRequest.find(ofType: requestType, forPath: path, in: fakeRequests) {
let fakeContext = makeContext(id: requestID, method: requestType.rawValue, url: try? composedURL(with: path), headers: headerFields ?? [:])
let fakeContext = RequestContext(id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields)
context = fakeContext
emit(.started(fakeContext))
let (fakeResult, fakeStatus, fakeBytes): (Result<T, NetworkingError>, Int, Int) = try await handleFakeRequest(fakeRequest, path: path, requestType: requestType)
Expand All @@ -44,7 +44,7 @@ extension Networking {
byteCount = fakeBytes
} else {
let request = try createRequest(path: path, requestType: requestType, body: body, query: query)
let requestContext = makeContext(id: requestID, method: requestType.rawValue, url: request.url, headers: request.allHTTPHeaderFields ?? [:])
let requestContext = RequestContext(id: requestID, requestType: requestType, url: request.url, headers: request.allHTTPHeaderFields)
context = requestContext
emit(.started(requestContext))

Expand All @@ -61,7 +61,7 @@ extension Networking {
result = handleResponse(responseData: exchange.data, response: exchange.response, path: path)
statusCode = exchange.response.statusCode
byteCount = exchange.data.count
responseMetadata = makeResponseMetadata(exchange.response, body: exchange.data)
responseMetadata = ResponseMetadata(response: exchange.response, body: exchange.data)
} else {
let collector = MetricsCollector()
let exchange = try await perform(request, collector: collector)
Expand All @@ -82,15 +82,15 @@ extension Networking {
byteCount = responseData.count
metrics = collector.metrics.flatMap(TransactionMetrics.init)
if let httpResponse = response as? HTTPURLResponse {
responseMetadata = makeResponseMetadata(httpResponse, body: responseData)
responseMetadata = ResponseMetadata(response: httpResponse, body: responseData)
}
}
}
} catch {
// A failure before the request context was built (e.g. URL building) still emits a paired
// .started/.completed so observers see every request.
if context == nil {
let errorContext = makeContext(id: requestID, method: requestType.rawValue, url: nil, headers: headerFields ?? [:])
let errorContext = RequestContext(id: requestID, requestType: requestType, url: nil, headers: headerFields)
context = errorContext
emit(.started(errorContext))
}
Expand Down Expand Up @@ -199,15 +199,11 @@ extension Networking {
// body encoding), so observers don't miss it.
func emitPreflightFailure<T: Decodable>(_ requestType: RequestType, path: String, error: NetworkingError) -> Result<T, NetworkingError> {
let requestID = UUID()
let context = makeContext(id: requestID, method: requestType.rawValue, url: try? composedURL(with: path), headers: headerFields ?? [:])
let context = RequestContext(id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields)
emit(.started(context))
return complete(.failure(error), context: context, statusCode: nil, byteCount: 0, metrics: nil, duration: .zero)
}

func makeContext(id: UUID, method: String, url: URL?, headers: [String: String]) -> RequestContext {
RequestContext(id: id, method: method, url: url, headers: headers)
}

// Persists status code and headers alongside the body so a cache hit reproduces real metadata.
private struct CachedResponse: Codable {
let statusCode: Int
Expand Down Expand Up @@ -274,7 +270,6 @@ extension Networking {
var request = URLRequest(
url: url,
requestType: requestType,
path: path,
contentType: body.contentType(boundary: boundary),
responseType: .json,
authorizationHeaderValue: authorizationHeaderValue,
Expand Down Expand Up @@ -329,7 +324,7 @@ extension Networking {
case .cancelled:
return .failure(.cancelled)
case .redirection, .clientError, .serverError, .unknown:
let error = HTTPError(statusCode: statusCode, metadata: makeResponseMetadata(httpResponse, body: responseData))
let error = HTTPError(statusCode: statusCode, metadata: ResponseMetadata(response: httpResponse, body: responseData))
return .failure(.http(error))
}
}
Expand All @@ -347,7 +342,7 @@ extension Networking {
let networkingJSON = JSONResponse(statusCode: httpResponse.statusCode, headers: headers, body: body)
return .success(networkingJSON as! T)
} catch let error as DecodingError {
return .failure(.decoding(error, makeResponseMetadata(httpResponse, body: responseData)))
return .failure(.decoding(error, ResponseMetadata(response: httpResponse, body: responseData)))
} catch {
return .failure(.invalidResponse) // JSONDecoder only throws DecodingError; unreachable.
}
Expand All @@ -357,17 +352,13 @@ extension Networking {
do {
return .success(try decoder.decode(T.self, from: responseData))
} catch let error as DecodingError {
return .failure(.decoding(error, makeResponseMetadata(httpResponse, body: responseData)))
return .failure(.decoding(error, ResponseMetadata(response: httpResponse, body: responseData)))
} catch {
return .failure(.invalidResponse) // JSONDecoder only throws DecodingError; unreachable.
}
}
}

private func makeResponseMetadata(_ httpResponse: HTTPURLResponse, body: Data) -> ResponseMetadata {
ResponseMetadata(response: httpResponse, body: body)
}

private func bodySnippet(from data: Data, limit: Int = 512) -> String? {
ResponseMetadata.bodySnippet(from: data, limit: limit)
}
Expand Down
12 changes: 8 additions & 4 deletions Sources/Networking/Networking+Private.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ extension Networking {
return nil
}

let returnedObject: Any? = responseType == .image ? Image(data: destinationURL.getData()) : destinationURL.getData()
// The file can vanish between the exists() check above and this read — the background
// sweep runs concurrently and doesn't share a lock with reads — so a read failure is a
// cache miss, not a crash.
guard let data = FileManager.default.contents(atPath: destinationURL.path) else { return nil }
let returnedObject: Any? = responseType == .image ? Image(data: data) : data
if let returnedObject {
cache.setObject(returnedObject as AnyObject, forKey: key as AnyObject)
// Re-warm: bump the file's mtime so an entry in active use never expires. Only happens
Expand Down Expand Up @@ -81,7 +85,7 @@ extension Networking {
let requestID = UUID()
let clock = ContinuousClock()
let startInstant = clock.now
let context = makeContext(id: requestID, method: requestType.rawValue, url: try? composedURL(with: path), headers: headerFields ?? [:])
let context = RequestContext(id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields)
emit(.started(context))

let result: Result<T, NetworkingError>
Expand Down Expand Up @@ -127,7 +131,7 @@ extension Networking {
let requestID = UUID()
let clock = ContinuousClock()
let startInstant = clock.now
let context = makeContext(id: requestID, method: requestType.rawValue, url: try? composedURL(with: path), headers: headerFields ?? [:])
let context = RequestContext(id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields)
emit(.started(context))

let result: Result<T, NetworkingError>
Expand Down Expand Up @@ -192,7 +196,7 @@ extension Networking {
}

func requestData(_ requestType: RequestType, path: String, cachingLevel: CachingLevel, responseType: ResponseType) async throws -> (Data, HTTPURLResponse) {
let request = URLRequest(url: try composedURL(with: path), requestType: requestType, path: path, contentType: nil, responseType: responseType, authorizationHeaderValue: authorizationHeaderValue, token: token, authorizationHeaderKey: authorizationHeaderKey, headerFields: headerFields)
let request = URLRequest(url: try composedURL(with: path), requestType: requestType, contentType: nil, responseType: responseType, authorizationHeaderValue: authorizationHeaderValue, token: token, authorizationHeaderKey: authorizationHeaderKey, headerFields: headerFields)

// Route through the interceptor chain so retry/auth-refresh apply to downloads too.
let exchange = try await perform(request)
Expand Down
10 changes: 6 additions & 4 deletions Sources/Networking/Networking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,12 @@ public actor Networking {
return String(Int(byte) % shardCount, radix: 16)
}

public static func splitBaseURLAndRelativePath(for path: String) -> (baseURL: String, relativePath: String) {
guard let encodedPath = path.encodeUTF8() else { fatalError("Couldn't encode path to UTF8: \(path)") }
guard let url = URL(string: encodedPath) else { fatalError("Path \(encodedPath) can't be converted to url") }
guard let baseURLWithDash = URL(string: "/", relativeTo: url)?.absoluteURL.absoluteString else { fatalError("Can't find absolute url of url: \(url)") }
public static func splitBaseURLAndRelativePath(for path: String) -> (baseURL: String, relativePath: String)? {
guard let encodedPath = path.encodeUTF8(),
let url = URL(string: encodedPath),
let baseURLWithDash = URL(string: "/", relativeTo: url)?.absoluteURL.absoluteString else {
return nil
}
let index = baseURLWithDash.index(before: baseURLWithDash.endIndex)
let baseURL = String(baseURLWithDash[..<index])
let relativePath = path.replacingOccurrences(of: baseURL, with: "")
Expand Down
9 changes: 9 additions & 0 deletions Sources/Networking/NetworkingEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ public struct RequestContext: Sendable {
public let headers: [String: String]
}

extension RequestContext {
// Owns the two normalizations every call site repeated: the HTTP method string and a nil header
// set. The URL stays a caller concern — it's resolved differently per site (best-effort
// `composedURL` from a path, a built request's URL, or nil on a pre-flight failure).
init(id: UUID, requestType: Networking.RequestType, url: URL?, headers: [String: String]?) {
self.init(id: id, method: requestType.rawValue, url: url, headers: headers ?? [:])
}
}

public enum Outcome: Sendable {
case success(statusCode: Int, byteCount: Int)
case failure(NetworkingError)
Expand Down
6 changes: 3 additions & 3 deletions Tests/NetworkingTests/NetworkingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,12 @@ class NetworkingTests: XCTestCase {
XCTAssertEqual(600.statusCodeType, Networking.StatusCodeType.unknown)
}

func testSplitBaseURLAndRelativePath() {
let (baseURL1, relativePath1) = Networking.splitBaseURLAndRelativePath(for: "https://rescuejuice.com/wp-content/uploads/2015/11/døgnvillburgere.jpg")
func testSplitBaseURLAndRelativePath() throws {
let (baseURL1, relativePath1) = try XCTUnwrap(Networking.splitBaseURLAndRelativePath(for: "https://rescuejuice.com/wp-content/uploads/2015/11/døgnvillburgere.jpg"))
XCTAssertEqual(baseURL1, "https://rescuejuice.com")
XCTAssertEqual(relativePath1, "/wp-content/uploads/2015/11/døgnvillburgere.jpg")

let (baseURL2, relativePath2) = Networking.splitBaseURLAndRelativePath(for: "http://example.com/basic-auth/user/passwd")
let (baseURL2, relativePath2) = try XCTUnwrap(Networking.splitBaseURLAndRelativePath(for: "http://example.com/basic-auth/user/passwd"))
XCTAssertEqual(baseURL2, "http://example.com")
XCTAssertEqual(relativePath2, "/basic-auth/user/passwd")
}
Expand Down