Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for uploads #43

Merged
merged 2 commits into from Jul 10, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
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 Expand Up @@ -70,6 +72,16 @@ generate api.github.yaml --output ./OctoKit --module "OctoKit"

> Check out [App Store Connect Swift SDK](https://github.com/AvdLee/appstoreconnect-swift-sdk) that starting with v2.0 uses [CreateAPI](https://github.com/kean/CreateAPI) for code generation.
### Other Extensions

Get is a lean framework with a lot of flexibility and customization points. It makes it very easy to learn and use, but for certain features, you'll need to install additional modules.

- [URLQueryEncoder](https://github.com/CreateAPI/URLQueryEncoder) – URL query encoder with support for all OpenAPI serialization options
- [swift-multipart-formdata](https://github.com/FelixHerrmann/swift-multipart-formdata) - build `multipart/form-data` in a type-safe way
- [NaiveDate](https://github.com/CreateAPI/NaiveDate) – working with dates ignoring time zones

Because Get provides complete access to the underlying `URLSession`, it's easy to use it with almost any framework that extends networking on Apple platforms.

## Minimum Requirements

| Get | Date | Swift | Xcode | Platforms |
Expand Down
113 changes: 84 additions & 29 deletions Sources/Get/APIClient.swift
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
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
10 changes: 10 additions & 0 deletions Sources/Get/Get.docc/Articles/integrations.md
Expand Up @@ -25,3 +25,13 @@ generate api.github.yaml --output ./OctoKit --module "OctoKit"
```

> Check out [App Store Connect Swift SDK](https://github.com/AvdLee/appstoreconnect-swift-sdk) that starting with v2.0 uses [CreateAPI](https://github.com/kean/CreateAPI) for code generation.
### Other Extensions

Get is a lean framework with a lot of flexibility and customization points. It makes it very easy to learn and use, but for certain features, you'll need to install additional modules.

- [URLQueryEncoder](https://github.com/CreateAPI/URLQueryEncoder) – URL query encoder with support for all OpenAPI serialization options
- [swift-multipart-formdata](https://github.com/FelixHerrmann/swift-multipart-formdata) - build `multipart/form-data` in a type-safe way
- [NaiveDate](https://github.com/CreateAPI/NaiveDate) – working with dates ignoring time zones

Because Get provides complete access to the underlying `URLSession`, it's easy to use it with almost any framework that extends networking on Apple platforms.
13 changes: 12 additions & 1 deletion Sources/Get/Get.docc/Extensions/APIClient+Extension.md
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
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
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
20 changes: 20 additions & 0 deletions Tests/GetTests/ClientTests.swift
Expand Up @@ -255,6 +255,26 @@ 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(url: url, dataType: .json, statusCode: 200, data: [
.post: json(named: "user")
]).register()

// WHEN

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
XCTAssertEqual(user.login, "kean")
}

// MARK: - Request Body

func testPassEncodableRequestBody() async throws {
Expand Down