Skip to content

Commit

Permalink
Add support for uploads
Browse files Browse the repository at this point in the history
  • Loading branch information
kean committed Jul 10, 2022
1 parent 6486638 commit 712101d
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 43 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ let response = try await client.send(Paths.user.get, delegate: delegate) {
}
```

In addition to sending quick requests, Get also supports downloads, uploads from file, authentication, auto-retries, logging, and more.

## Documentation

Learn how to use Get by going through the [documentation](https://kean-docs.github.io/get/documentation/get/) created using DocC.
Expand Down
113 changes: 84 additions & 29 deletions Sources/Get/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,25 +135,12 @@ public actor APIClient {
configure: ((inout URLRequest) -> Void)?,
_ decode: @escaping (Data) async throws -> U
) async throws -> Response<U> {
var request = try await makeURLRequest(for: request)
configure?(&request)
let response = try await _send(request, attempts: 1, delegate: delegate)
let value = try await decode(response.value)
return response.map { _ in value } // Keep metadata
}

private func _send(_ request: URLRequest, attempts: Int, delegate: URLSessionDataDelegate?) async throws -> Response<Data> {
do {
var request = request
try await self.delegate.client(self, willSendRequest: &request)
let request = try await makeURLRequest(for: request, configure)
return try await performWithRetries(request: request) { request in
let (data, response, metrics) = try await dataLoader.data(for: request, session: session, delegate: delegate)
try validate(response: response, data: data)
return Response(value: data, data: data, request: request, response: response, metrics: metrics)
} catch {
guard try await self.delegate.client(self, shouldRetryRequest: request, attempts: attempts, error: error) else {
throw error
}
return try await _send(request, attempts: attempts + 1, delegate: delegate)
let value = try await decode(data)
return Response(value: value, data: data, request: request, response: response, metrics: metrics)
}
}

Expand Down Expand Up @@ -207,8 +194,7 @@ public actor APIClient {
delegate: URLSessionDownloadDelegate? = nil,
configure: ((inout URLRequest) -> Void)? = nil
) async throws -> Response<URL> {
var request = try await makeURLRequest(for: request)
configure?(&request)
let request = try await makeURLRequest(for: request, configure)
return try await _download(request, attempts: 1, delegate: delegate)
}

Expand All @@ -217,25 +203,92 @@ public actor APIClient {
attempts: Int,
delegate: URLSessionDownloadDelegate?
) async throws -> Response<URL> {
do {
var request = request
try await self.delegate.client(self, willSendRequest: &request)
try await performWithRetries(request: request) { request in
let (location, response, metrics) = try await dataLoader.download(for: request, session: session, delegate: delegate)
try validate(response: response, data: Data())
return Response(value: location, data: Data(), request: request, response: response, metrics: metrics)
} catch {
guard try await self.delegate.client(self, shouldRetryRequest: request, attempts: attempts, error: error) else {
throw error
}
return try await _download(request, attempts: attempts + 1, delegate: delegate)
}
}

#endif

// MARK: Upload

/// Convenience method to upload data from the file.
///
/// - parameters:
/// - request: The URLRequest for which to upload data.
/// - fileURL: File to upload.
/// - delegate: Task-specific delegate.
/// - configure: Modifies the underlying `URLRequest` before sending it.
///
/// Returns decoded response.
public func upload<T: Decodable>(
for request: Request<T>,
fromFile fileURL: URL,
delegate: URLSessionTaskDelegate? = nil,
configure: ((inout URLRequest) -> Void)? = nil
) async throws -> Response<T> {
let request = try await makeURLRequest(for: request, configure)
return try await _upload(request, fromFile: fileURL, delegate: delegate, decode)
}

/// Convenience method to upload data from the file.
///
/// - parameters:
/// - request: The URLRequest for which to upload data.
/// - fileURL: File to upload.
/// - delegate: Task-specific delegate.
/// - configure: Modifies the underlying `URLRequest` before sending it.
///
/// Returns decoded response.
public func upload(
for request: Request<Void>,
fromFile fileURL: URL,
delegate: URLSessionTaskDelegate? = nil,
configure: ((inout URLRequest) -> Void)? = nil
) async throws -> Response<Void> {
let request = try await makeURLRequest(for: request, configure)
return try await _upload(request, fromFile: fileURL, delegate: delegate, { _ in () })
}

private func _upload<T>(
_ request: URLRequest,
fromFile fileURL: URL,
delegate: URLSessionTaskDelegate? = nil,
_ decode: @escaping (Data) async throws -> T
) async throws -> Response<T> {
try await performWithRetries(request: request) { request in
let (data, response, metrics) = try await dataLoader.upload(for: request, fromFile: fileURL, session: session, delegate: delegate)
try validate(response: response, data: data)
let value = try await decode(data)
return Response(value: value, data: data, request: request, response: response, metrics: metrics)
}
}

// MARK: Helpers

private func makeURLRequest<T>(for request: Request<T>) async throws -> URLRequest {
private func performWithRetries<T>(
request: URLRequest,
attempts: Int = 1,
send: (URLRequest) async throws -> T
) async throws -> T {
do {
var request = request
try await self.delegate.client(self, willSendRequest: &request)
return try await send(request)
} catch {
guard try await self.delegate.client(self, shouldRetryRequest: request, attempts: attempts, error: error) else {
throw error
}
return try await performWithRetries(request: request, attempts: attempts + 1, send: send)
}
}

private func makeURLRequest<T>(
for request: Request<T>,
_ configure: ((inout URLRequest) -> Void)?
) async throws -> URLRequest {
let url = try makeURL(path: request.path, query: request.query)
var urlRequest = URLRequest(url: url)
urlRequest.allHTTPHeaderFields = request.headers
Expand All @@ -247,6 +300,7 @@ public actor APIClient {
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
configure?(&urlRequest)
return urlRequest
}

Expand All @@ -255,7 +309,8 @@ public actor APIClient {
return url
}
func makeAbsoluteURL() -> URL? {
(path.starts(with: "/") || URL(string: path)?.scheme == nil) ? conf.baseURL?.appendingPathComponent(path) : URL(string: path)
let isRelative = path.starts(with: "/") || URL(string: path)?.scheme == nil
return isRelative ? conf.baseURL?.appendingPathComponent(path) : URL(string: path)
}
guard let url = makeAbsoluteURL(),
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
Expand Down
35 changes: 29 additions & 6 deletions Sources/Get/DataLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ final class DataLoader: NSObject, URLSessionDataDelegate, URLSessionDownloadDele
private lazy var downloadDirectoryURL = FileManager.default.temporaryDirectory.appendingPathComponent("com.github.kean.get/Downloads/")

func data(for request: URLRequest, session: URLSession, delegate: URLSessionDataDelegate?) async throws -> (Data, URLResponse, URLSessionTaskMetrics?) {
final class Box { var task: URLSessionTask? }
let box = Box()
return try await withTaskCancellationHandler(handler: {
box.task?.cancel()
Expand All @@ -44,7 +43,6 @@ final class DataLoader: NSObject, URLSessionDataDelegate, URLSessionDownloadDele
}

func download(for request: URLRequest, session: URLSession, delegate: URLSessionDownloadDelegate?) async throws -> (URL, URLResponse, URLSessionTaskMetrics?) {
final class Box { var task: URLSessionTask? }
let box = Box()
return try await withTaskCancellationHandler(handler: {
box.task?.cancel()
Expand All @@ -62,6 +60,28 @@ final class DataLoader: NSObject, URLSessionDataDelegate, URLSessionDownloadDele
})
}

func upload(for request: URLRequest, fromFile fileURL: URL, session: URLSession, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse, URLSessionTaskMetrics?) {
let box = Box()
return try await withTaskCancellationHandler(handler: {
box.task?.cancel()
}, operation: {
try await withUnsafeThrowingContinuation { continuation in
let task = session.uploadTask(with: request, fromFile: fileURL)
session.delegateQueue.addOperation {
let handler = DataTaskHandler(delegate: delegate)
handler.completion = continuation.resume(with:)
self.handlers[task] = handler
}
task.resume()
box.task = task
}
})
}

private final class Box {
var task: URLSessionTask?
}

// MARK: - URLSessionDelegate

func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
Expand Down Expand Up @@ -94,18 +114,21 @@ final class DataLoader: NSObject, URLSessionDataDelegate, URLSessionDownloadDele
handler.delegate?.urlSession?(session, task: task, didCompleteWithError: error)
userTaskDelegate?.urlSession?(session, task: task, didCompleteWithError: error)
#endif
if let handler = handler as? DataTaskHandler {
switch handler {
case let handler as DataTaskHandler:
if let response = task.response, error == nil {
handler.completion?(.success((handler.data ?? Data(), response, handler.metrics)))
} else {
handler.completion?(.failure(error ?? URLError(.unknown)))
}
} else if let handler = handler as? DownloadTaskHandler {
case let handler as DownloadTaskHandler:
if let location = handler.location, let response = task.response, error == nil {
handler.completion?(.success((location, response, handler.metrics)))
} else {
handler.completion?(.failure(error ?? URLError(.unknown)))
}
default:
break
}
}

Expand Down Expand Up @@ -277,8 +300,8 @@ private final class DataTaskHandler: TaskHandler {
var completion: Completion?
var data: Data?

init(delegate: URLSessionDataDelegate?) {
self.dataDelegate = delegate
override init(delegate: URLSessionTaskDelegate?) {
self.dataDelegate = delegate as? URLSessionDataDelegate
super.init(delegate: delegate)
}
}
Expand Down
13 changes: 12 additions & 1 deletion Sources/Get/Get.docc/Extensions/APIClient+Extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ let response = try await client.send(Paths.user.get, delegate: delegate) {
}
```
### Downloading Data
### Downloading and Uploading Data
To fetch the response data, use ``data(for:delegate:configure:)`` or use ``download(for:delegate:configure:)`` to download it to the file.
Expand All @@ -51,6 +51,12 @@ let response = try await client.download(for: .get("/user"))
let url = response.location
```
``APIClient`` also provides a convenience method ``upload(for:fromFile:delegate:configure:)-5w52n`` for uploading data from a file:
```swift
try await clien.upload(for: .post("/avatar"), fromFile: fileURL)
```
### Client Delegate
One of the ways you can customize the client is by providing a custom delegate implementing `APIClientDelegate` protocol. For example, you can use it to implement an authorization flow.
Expand Down Expand Up @@ -113,3 +119,8 @@ final class YourSessionDelegate: URLSessionTaskDelegate {
### Downloads

- ``download(for:delegate:configure:)``

### Uploads

- ``upload(for:fromFile:delegate:configure:)-5w52n``
- ``upload(for:fromFile:delegate:configure:)-5iex0``
2 changes: 2 additions & 0 deletions Sources/Get/Get.docc/Get.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ let response = try await client.send(Paths.user.get, delegate: delegate) {
}
```

In addition to sending quick requests, Get also supports downloads, uploads from file, authentication, auto-retries, logging, and more.

## Sponsors 💖

[Support](https://github.com/sponsors/kean) Get on GitHub Sponsors.
Expand Down
4 changes: 0 additions & 4 deletions Sources/Get/Response.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ public struct Response<T> {
self.response = response
self.metrics = metrics
}

func map<U>(_ closure: (T) -> U) -> Response<U> {
Response<U>(value: closure(value), data: data, request: request, response: response, metrics: metrics)
}
}

extension Response where T == URL {
Expand Down
27 changes: 24 additions & 3 deletions Tests/GetTests/ClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,16 @@ final class APIClientTests: XCTestCase {
let client = makeSUT()

let url = URL(string: "https://api.github.com/user")!
Mock.get(url: url, json: "user").register()
Mock(url: url, dataType: .json, statusCode: 200, data: [
.post: json(named: "user")
]).register()

// WHEN
let response = try await client.data(for: .get("/user"))

let fileURL = try XCTUnwrap(Bundle.module.url(forResource: "user", withExtension: "json"))
let user: User = try await client.upload(for: .post("/user"), fromFile: fileURL).value

// THEN
let user = try JSONDecoder().decode(User.self, from: response.data)
XCTAssertEqual(user.login, "kean")
}

Expand All @@ -255,6 +258,24 @@ final class APIClientTests: XCTestCase {
}
#endif

// MARK: - Uploads

func testUpload() async throws {
// GIVEN
let client = makeSUT()

let url = URL(string: "https://api.github.com/user")!
Mock.get(url: url, json: "user").register()

// WHEN
let response = try await client.download(for: .get("/user"))

// THEN
let data = try Data(contentsOf: response.location)
let user = try JSONDecoder().decode(User.self, from: data)
XCTAssertEqual(user.login, "kean")
}

// MARK: - Request Body

func testPassEncodableRequestBody() async throws {
Expand Down

0 comments on commit 712101d

Please sign in to comment.