Skip to content

Commit

Permalink
Merge pull request #4 from igashev/develop
Browse files Browse the repository at this point in the history
Async stream
  • Loading branch information
igashev authored Mar 30, 2023
2 parents d7f9579 + 959de28 commit 66438cf
Show file tree
Hide file tree
Showing 13 changed files with 707 additions and 729 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.5
// swift-tools-version:5.7

import PackageDescription

Expand Down
65 changes: 43 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ NetworkRequester is an HTTP Combine-only networking library.
- [Requirements](#requirements)
- [Installation](#installation)
- [Usage](#usage)
- [Conclusion](#conclusion)

## Requirements

Expand All @@ -21,7 +20,10 @@ The [Swift Package Manager](https://swift.org/package-manager/) is a tool for ma
NetworkRequester supports only SPM and adding it as a dependency is done just by including the URL into the `dependencies` value of your `Package.swift`.
```swift
dependencies: [
.package(url: "https://github.com/igashev/NetworkRequester.git", .upToNextMajor(from: "1.2.0"))
.package(
url: "https://github.com/igashev/NetworkRequester.git",
.upToNextMajor(from: "1.2.0")
)
]
```
Or by using the integrated tool of Xcode.
Expand All @@ -37,17 +39,13 @@ let requestBuilder = URLRequestBuilder(
environment: Environment.production,
endpoint: UsersEndpoint.users,
httpMethod: .get,
httpHeaders: [.json, .authorization(bearerToken: "secretBearerToken")],
httpHeaders: [
.json,
.authorization(bearerToken: "secretBearerToken")
],
httpBody: nil,
queryParameters: nil
)

// Building a URLRequest
do {
let urlRequest = try requestBuilder.build()
} catch {
// Possible errors that could be thrown here are NetworkingError.buildingURL and NetworkingError.encoding(error:)
}
```

### Calling requests
Expand All @@ -56,29 +54,52 @@ There are two options with which to make the actual network request.

The first option is using plain `URLRequest`.
```swift
let url = URL(string: "https://example.get.request.com")!
struct User: Decodable {
let name: String
}

struct BackendError: DecodableError {
let errorCode: Int
let localizedError: String
}

let url = URL(string: "https://amazingapi.com/v1/users")!
let urlRequest = URLRequest(url: url)

let caller = URLRequestCaller(decoder: JSONDecoder())
let examplePublisher: AnyPublisher<Void, NetworkingError> = caller.call(using: urlRequest) // Expects no response data as Void is specified as Output
let caller = AsyncCaller(decoder: JSONDecoder())
let user: User = try await caller.call(
using: urlRequest,
errorType: BackendError.self
)
```

The second option is using `URLRequestBuilder`.
```swift
struct User: Decodable {
let name: String
}

struct BackendError: DecodableError {
let errorCode: Int
let localizedError: String
}

let requestBuilder = URLRequestBuilder(
environment: Environment.production,
endpoint: UsersEndpoint.users,
environment: "https://amazingapi.com",
endpoint: "v1/users",
httpMethod: .get,
httpHeaders: [.json, .authorization(bearerToken: "secretBearerToken")],
httpHeaders: [
.json,
.authorization(bearerToken: "secretBearerToken")
],
httpBody: nil,
queryParameters: nil
)

let caller = URLRequestCaller(decoder: JSONDecoder())
let examplePublisher: AnyPublisher<User, NetworkingError> = caller.call(using: requestBuilder) // Expects response data as User is specified as Output
let caller = AsyncCaller(decoder: JSONDecoder())
let user: User = try await caller.call(
using: requestBuilder,
errorType: BackendError.self
)
```
Take into account that when a response data is expected, a type that conforms to `Encodable` should be specified as `Output`. Otherwise `Void`.

## Conclusion

NetworkRequester is still very young. Improvements and new functionalities will be coming. Pull requests and suggestions are very welcomed.
108 changes: 88 additions & 20 deletions Sources/NetworkRequester/Callers/AsyncCaller.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,46 @@
import Foundation

/// Use this object to make network calls and receive decoded values using the new structured concurrency (async/await).
///
/// ```
/// struct User: Decodable {
/// let name: String
/// }
///
/// struct BackendError: DecodableError {
/// let errorCode: Int
/// let localizedError: String
/// }
///
/// let requestBuilder = URLRequestBuilder(
/// environment: "https://amazingapi.com",
/// endpoint: "v1/users",
/// httpMethod: .get,
/// httpHeaders: [
/// .json,
/// .authorization(bearerToken: "secretBearerToken")
/// ],
/// httpBody: nil,
/// queryParameters: nil
/// )
///
/// let caller = AsyncCaller(decoder: JSONDecoder())
/// let user: User = try await caller.call(
/// using: requestBuilder,
/// errorType: BackendError.self
/// )
/// ```
public struct AsyncCaller {
private let middleware: [Middleware]
private let urlSession: URLSession
public let urlSession: URLSession
public let middlewares: [Middleware]

private let utility: CallerUtility
private var middlewaresOnRequestAsyncStream: AsyncThrowingStream<Middleware, Error> {
.init { continuation in
middlewares.forEach { continuation.yield($0) }
continuation.finish()
}
}

/// Initialises an object which can make network calls.
/// - Parameters:
Expand All @@ -13,50 +49,82 @@ public struct AsyncCaller {
/// - middleware: Middleware that is injected in the networking events.
public init(
urlSession: URLSession = .shared,
decoder: JSONDecoder,
middleware: [Middleware] = []
middleware: [Middleware] = [],
decoder: JSONDecoder
) {
self.urlSession = urlSession
self.middleware = middleware
self.middlewares = middleware
self.utility = .init(decoder: decoder)
}

public func call<D: Decodable, DE: DecodableError>(
using builder: URLRequestBuilder,
errorType: DE.Type
) async throws -> D {
var request = try builder.build()
middleware.forEach { $0.onRequest(&request) }
try await call(using: builder.build(), errorType: errorType)
}

public func call<DE: DecodableError>(
using builder: URLRequestBuilder,
errorType: DE.Type
) async throws {
try await call(using: builder.build(), errorType: errorType)
}

public func call<D: Decodable, DE: DecodableError>(
using request: URLRequest,
errorType: DE.Type
) async throws -> D {
var mutableRequest = request
try await runMiddlewaresOnRequest(request: &mutableRequest)

do {
let (data, response) = try await urlSession.data(for: request)
let tryMap = try utility.checkResponseForErrors(data: data, urlResponse: response, errorType: errorType)
let (data, response) = try await urlSession.data(for: mutableRequest)
let tryMap = try utility.checkResponseForErrors(
data: data,
urlResponse: response,
errorType: errorType
)
let tryMap2: D = try utility.decodeIfNecessary(tryMap)
middleware.forEach { $0.onResponse(data: data, response: response) }
middlewares.forEach { $0.onResponse(data: data, response: response) }
return tryMap2
} catch {
let mappedError = utility.mapError(error)
middleware.forEach { $0.onError(mappedError, request: request) }
middlewares.forEach { $0.onError(mappedError, request: mutableRequest) }
throw mappedError
}
}

public func call<E: DecodableError>(
using builder: URLRequestBuilder,
errorType: E.Type
public func call<DE: DecodableError>(
using request: URLRequest,
errorType: DE.Type
) async throws {
var request = try builder.build()
middleware.forEach { $0.onRequest(&request) }
var mutableRequest = request
try await runMiddlewaresOnRequest(request: &mutableRequest)

do {
let (data, response) = try await urlSession.data(for: request)
let tryMap = try utility.checkResponseForErrors(data: data, urlResponse: response, errorType: errorType)
let (data, response) = try await urlSession.data(for: mutableRequest)
let tryMap = try utility.checkResponseForErrors(
data: data,
urlResponse: response,
errorType: errorType
)
try utility.tryMapEmptyResponseBody(data: tryMap)
middleware.forEach { $0.onResponse(data: data, response: response) }
middlewares.forEach { $0.onResponse(data: data, response: response) }
} catch {
let mappedError = utility.mapError(error)
middleware.forEach { $0.onError(mappedError, request: request) }
middlewares.forEach { $0.onError(mappedError, request: mutableRequest) }
throw mappedError
}
}

private func runMiddlewaresOnRequest(request: inout URLRequest) async throws {
guard !middlewares.isEmpty else {
return
}

for try await middleware in middlewaresOnRequestAsyncStream {
try await middleware.onRequest(&request)
}
}
}
30 changes: 19 additions & 11 deletions Sources/NetworkRequester/Callers/CallerUtility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,37 @@ struct CallerUtility {
let httpResponse = urlResponse as? HTTPURLResponse,
let status = HTTPStatus(rawValue: httpResponse.statusCode)
else {
throw NetworkingError.networking(
status: .internalServerError,
error: try? decoder.decode(DE.self, from: data)
)
throw NetworkingError.unknown(underlyingError: try? decoder.decode(DE.self, from: data))
}

guard status.isSuccess else {
throw NetworkingError.networking(status: status, error: try? decoder.decode(DE.self, from: data))
throw NetworkingError.networking(
status: status,
underlyingError: try? decoder.decode(DE.self, from: data)
)
}

return data
}

/// Maps the response's error to the more contextual `NetworkingError` providing the underlying error as well
/// for further clarification.
/// - Parameter error: The thrown error.
/// - Returns: The more contextual `NetworkingError`.
func mapError(_ error: Error) -> NetworkingError {
switch error {
case let decodingError as DecodingError:
return NetworkingError.decoding(error: decodingError)
return NetworkingError.decoding(underlyingError: decodingError)
case let networkingError as NetworkingError:
return networkingError
default:
return .unknown(error)
return .unknown(underlyingError: error)
}
}

/// Makes sure that the response's data is empty. This is useful when empty response data is expected.
/// Throws `DecodingError.dataCorrupted`when data is not empty.
/// - Parameter data: The data to be checked for emptiness.
func tryMapEmptyResponseBody(data: Data) throws {
guard !data.isEmpty else {
return
Expand All @@ -54,13 +61,14 @@ struct CallerUtility {
throw DecodingError.dataCorrupted(context)
}

/// Decodes data to the provided generic parameter using a `JSONDecoder`.
/// In cases where `D` is `Data` return the raw data, instead of attempting to decode it. Otherwise - run through the decoder.
/// - Parameter data: Data to decode.
/// - Returns: Returns `Data` when the the generic is `Data` or any other `Decodable` that is run through the decoder.
func decodeIfNecessary<D: Decodable>(_ data: Data) throws -> D {
// In cases where D is Data, we return the raw data instead of attempting to decode it
if let data = data as? D {
return data
}
// Otherwise - run through the decoder
else {
} else {
return try decoder.decode(D.self, from: data)
}
}
Expand Down
Loading

0 comments on commit 66438cf

Please sign in to comment.