@@ -6,7 +6,8 @@ public extension URLServer {
66 /// - Parameters:
77 /// - endpoint: The endpoint
88 /// - configuring: Optional request configuration to apply before sending
9- /// - Throws: Throws an APIError if the request fails or server returns an error
9+ /// - Throws: Throws an ``APIError`` if the request fails or server returns an error,
10+ /// or an error from ``RequestConfiguring/configure(_:)`` if configuration fails.
1011 func call( endpoint: Endpoint , configuring: RequestConfiguring ? = nil ) async throws {
1112 _ = try await execute ( endpoint: endpoint, configuring: configuring)
1213 }
@@ -15,7 +16,8 @@ public extension URLServer {
1516 /// - Parameters:
1617 /// - endpoint: The endpoint
1718 /// - configuring: Optional request configuration to apply before sending
18- /// - Throws: Throws an APIError if the request fails or server returns an error
19+ /// - Throws: Throws an ``APIError`` if the request fails or server returns an error,
20+ /// or an error from ``RequestConfiguring/configure(_:)`` if configuration fails.
1921 /// - Returns: Plain data returned with the HTTP Response
2022 func call( data endpoint: Endpoint , configuring: RequestConfiguring ? = nil ) async throws -> Data {
2123 try await execute ( endpoint: endpoint, configuring: configuring) . data
@@ -25,7 +27,9 @@ public extension URLServer {
2527 /// - Parameters:
2628 /// - endpoint: The endpoint
2729 /// - configuring: Optional request configuration to apply before sending
28- /// - Throws: Throws an APIError if the request fails, server returns an error, or decoding fails
30+ /// - Throws: Throws an ``APIError`` if the request fails or server returns an error.
31+ /// Throws a `DecodingError` directly if response decoding fails (decoding errors are not
32+ /// routed through ``URLServer/ErrorType``).
2933 /// - Returns: Instance of the required type
3034 func call< EP: ResponseEndpoint > ( response endpoint: EP , configuring: RequestConfiguring ? = nil ) async throws -> EP . Response {
3135 let result = try await execute ( endpoint: endpoint, configuring: configuring)
@@ -36,22 +40,48 @@ public extension URLServer {
3640 throw error
3741 }
3842 }
43+
44+ /// Downloads a file from the specified endpoint to a temporary location.
45+ /// - Parameters:
46+ /// - endpoint: The endpoint
47+ /// - configuring: Optional request configuration to apply before sending
48+ /// - Throws: Throws an ``APIError`` if the request fails or server returns an error,
49+ /// or an error from ``RequestConfiguring/configure(_:)`` if configuration fails.
50+ /// - Returns: The location of a temporary file where the server's response is stored.
51+ /// You must move this file or open it for reading before the async function returns. Otherwise, the file
52+ /// is deleted, and the data is lost.
53+ func download( endpoint: Endpoint , configuring: RequestConfiguring ? = nil ) async throws -> URL {
54+ let ( urlRequest, observers) = try await prepareObservers ( endpoint: endpoint, configuring: configuring)
55+
56+ let ( localURL, response) : ( URL , URLResponse )
57+ do {
58+ ( localURL, response) = try await urlSession. download ( for: urlRequest)
59+ } catch {
60+ observers. forEach { $0. didFail ( request: urlRequest, error: error) }
61+ throw error
62+ }
63+
64+ observers. forEach { $0. didReceiveResponse ( for: urlRequest, response: response, data: nil ) }
65+ try checkForError ( data: nil , response: response, request: urlRequest, observers: observers)
66+
67+ return localURL
68+ }
3969}
4070
4171// MARK: - Private helpers
4272
4373private struct ExecuteResult {
4474 let data : Data
4575 let request : URLRequest
46- let observers : [ AnyObserverToken ]
76+ let observers : [ BoundObserverContext ]
4777}
4878
4979private extension URLServer {
5080
5181 /// Core execution method that builds the request, notifies observers, performs the network call,
5282 /// and handles errors.
5383 func execute( endpoint: Endpoint , configuring: RequestConfiguring ? ) async throws -> ExecuteResult {
54- let ( urlRequest, observers) = try await prepareRequest ( endpoint: endpoint, configuring: configuring)
84+ let ( urlRequest, observers) = try await prepareObservers ( endpoint: endpoint, configuring: configuring)
5585
5686 let file = ( endpoint as? UploadEndpoint ) ? . file
5787
@@ -72,20 +102,15 @@ private extension URLServer {
72102
73103 return ExecuteResult ( data: data, request: urlRequest, observers: observers)
74104 }
75- }
76-
77- // MARK: - Shared helpers
78-
79- extension URLServer {
80105
81- /// Builds the URLRequest for the endpoint, applies optional configuration, and creates observer tokens .
82- func prepareRequest (
106+ /// Builds the URLRequest for the endpoint, applies optional configuration, and creates observer contexts .
107+ func prepareObservers (
83108 endpoint: Endpoint ,
84109 configuring: RequestConfiguring ?
85- ) async throws -> ( URLRequest , [ AnyObserverToken ] ) {
110+ ) async throws -> ( URLRequest , [ BoundObserverContext ] ) {
86111 var urlRequest = try await buildRequest ( endpoint: endpoint)
87112 try await configuring? . configure ( & urlRequest)
88- let observers = networkObservers. map { AnyObserverToken ( observer: $0, request: urlRequest) }
113+ let observers = networkObservers. map { BoundObserverContext ( observer: $0, request: urlRequest) }
89114 return ( urlRequest, observers)
90115 }
91116
@@ -94,7 +119,7 @@ extension URLServer {
94119 data: Data ? ,
95120 response: URLResponse ,
96121 request: URLRequest ,
97- observers: [ AnyObserverToken ]
122+ observers: [ BoundObserverContext ]
98123 ) throws {
99124 if let error = ErrorType ( data: data, response: response, error: nil , decoding: decoding) {
100125 observers. forEach { $0. didFail ( request: request, error: error) }
@@ -103,8 +128,13 @@ extension URLServer {
103128 }
104129}
105130
106- /// Type-erasing wrapper that captures an observer and its context from `willSendRequest`.
107- final class AnyObserverToken : @unchecked Sendable {
131+ /// Captures an observer and its context from `willSendRequest`, binding the lifecycle callbacks
132+ /// for a single request. Created at request start and consumed before the call returns.
133+ ///
134+ /// Marked `@unchecked Sendable` because the stored closures capture a `Sendable` observer
135+ /// and its `Sendable` context. Instances are created and consumed within a single async call
136+ /// and never shared across task boundaries.
137+ private final class BoundObserverContext : @unchecked Sendable {
108138 private let _didReceiveResponse : ( URLRequest , URLResponse ? , Data ? ) -> Void
109139 private let _didFail : ( URLRequest , Error ) -> Void
110140
0 commit comments