Skip to content

Commit

Permalink
tech(session): use async as main implementation (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
pjechris authored May 22, 2022
1 parent 9e6a06c commit f8e4dbe
Show file tree
Hide file tree
Showing 13 changed files with 269 additions and 222 deletions.
3 changes: 3 additions & 0 deletions Sources/SimpleHTTP/DataCoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ public protocol ContentDataDecoder: DataDecoder {
/// a http content type
static var contentType: HTTPContentType { get }
}

/// A function converting data when a http error occur into a custom error
public typealias DataErrorDecoder = (Data) throws -> Error
31 changes: 0 additions & 31 deletions Sources/SimpleHTTP/Foundation/Publisher+Validate.swift

This file was deleted.

27 changes: 22 additions & 5 deletions Sources/SimpleHTTP/Foundation/URLSession+Publisher.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import Foundation

extension URLSession {
/// Return a dataTaskPublisher as a `DataPublisher`
public func dataPublisher(for request: URLRequest) -> Session.RequestDataPublisher {
dataTaskPublisher(for: request)
.mapError { $0 as Error }
.eraseToAnyPublisher()
@available(iOS, deprecated: 15.0, message: "Use built-in API instead")
public func data(for urlRequest: URLRequest) async throws -> (Data, URLResponse) {
try await withCheckedThrowingContinuation { promise in
self.dataTask(with: urlRequest) { data, response, error in
if let error = error {
promise.resume(throwing: error)
}

guard let data = data, let response = response else {
return promise.resume(throwing: URLError(.badServerResponse))
}

promise.resume(returning: (data, response))
}
.resume()
}
}

public func data(for urlRequest: URLRequest) async throws -> URLDataResponse {
let (data, response) = try await data(for: urlRequest)

return URLDataResponse(data: data, response: response as! HTTPURLResponse)
}
}
29 changes: 29 additions & 0 deletions Sources/SimpleHTTP/Interceptor/Interceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,32 @@ public protocol ResponseInterceptor {
/// - Parameter request: the request that was sent to the server
func receivedResponse<Output>(_ result: Result<Output, Error>, for request: Request<Output>)
}

extension RequestInterceptor {
func shouldRescueRequest<Output>(_ request: Request<Output>, error: Error) async throws -> Bool {
var cancellable: Set<AnyCancellable> = []
let onCancel = { cancellable.removeAll() }

guard let rescuePublisher = rescueRequest(request, error: error) else {
return false
}

return try await withTaskCancellationHandler(
handler: { onCancel() },
operation: {
try await withCheckedThrowingContinuation { continuation in
rescuePublisher
.sink(
receiveCompletion: {
if case let .failure(error) = $0 {
return continuation.resume(throwing: error)
}
},
receiveValue: { _ in
continuation.resume(returning: true)
})
.store(in: &cancellable)
}
})
}
}
21 changes: 21 additions & 0 deletions Sources/SimpleHTTP/Response/DataResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

public struct URLDataResponse {
public let data: Data
public let response: HTTPURLResponse
}

extension URLDataResponse {
public func validate(errorDecoder: DataErrorDecoder? = nil) throws {
do {
try response.validateStatusCode()
}
catch let error as HTTPError {
guard let decoder = errorDecoder, !data.isEmpty else {
throw error
}

throw try decoder(data)
}
}
}
39 changes: 0 additions & 39 deletions Sources/SimpleHTTP/Session/Session+Async.swift

This file was deleted.

44 changes: 44 additions & 0 deletions Sources/SimpleHTTP/Session/Session+Combine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Foundation

#if canImport(Combine)
import Combine

extension Session {
/// Return a publisher performing request and returning `Output` data
///
/// The request is validated and decoded appropriately on success.
/// - Returns: a Publisher emitting Output on success, an error otherwise
public func publisher<Output: Decodable>(for request: Request<Output>) -> AnyPublisher<Output, Error> {
let subject = PassthroughSubject<Output, Error>()

Task {
do {
subject.send(try await response(for: request))
subject.send(completion: .finished)
}
catch {
subject.send(completion: .failure(error))
}
}

return subject.eraseToAnyPublisher()
}

public func publisher(for request: Request<Void>) -> AnyPublisher<Void, Error> {
let subject = PassthroughSubject<Void, Error>()

Task {
do {
subject.send(try await response(for: request))
subject.send(completion: .finished)
}
catch {
subject.send(completion: .failure(error))
}
}

return subject.eraseToAnyPublisher()
}
}

#endif
133 changes: 55 additions & 78 deletions Sources/SimpleHTTP/Session/Session.swift
Original file line number Diff line number Diff line change
@@ -1,119 +1,96 @@
import Foundation
import Combine

/// Primary class of the library used to perform http request using `Request` objects
/// Primary class of the library used to perform http request using a `Request` object
public class Session {
/// Data returned by a http request
public typealias RequestData = URLSession.DataTaskPublisher.Output

/// a Publisher emitting `RequestData`
public typealias RequestDataPublisher = AnyPublisher<RequestData, Error>

/// a function returning a `RequestData` from a `URLRequest`
public typealias URLRequestTask = (URLRequest) async throws -> URLDataResponse

let baseURL: URL
let config: SessionConfiguration
/// a closure returning a publisher based for a given `URLRequest`
let urlRequestPublisher: (URLRequest) -> RequestDataPublisher
/// a closure returning a `DataResponse` from a `URLRequest`
let dataTask: URLRequestTask

/// init the class using a `URLSession` instance
/// - Parameter baseURL: common url for all the requests. Allow to switch environments easily
/// - Parameter configuration: session configuration to use
/// - Parameter urlSession: `URLSession` instance to use to make requests.
public convenience init(baseURL: URL, configuration: SessionConfiguration = .init(), urlSession: URLSession) {
self.init(
baseURL: baseURL,
configuration: configuration,
dataPublisher: urlSession.dataPublisher(for:)
)
public convenience init(
baseURL: URL,
configuration: SessionConfiguration = SessionConfiguration(),
urlSession: URLSession = .shared
) {
self.init(baseURL: baseURL, configuration: configuration, dataTask: { try await urlSession.data(for: $0) })
}

/// init the class with a base url for request
/// - Parameter baseURL: common url for all the requests. Allow to switch environments easily
/// - Parameter configuration: session configuration to use
/// - Parameter dataPublisher: publisher used by the class to make http requests. If none provided it default
/// - Parameter dataTask: publisher used by the class to make http requests. If none provided it default
/// to `URLSession.dataPublisher(for:)`
public init(
baseURL: URL,
configuration: SessionConfiguration = SessionConfiguration(),
dataPublisher: @escaping (URLRequest) -> RequestDataPublisher = { URLSession.shared.dataPublisher(for: $0) }
dataTask: @escaping URLRequestTask
) {
self.baseURL = baseURL
self.config = configuration
self.urlRequestPublisher = dataPublisher
self.dataTask = dataTask
}
/// Return a publisher performing request and returning `Output` data

/// Return a publisher performing `request` and returning `Output`
///
/// The request is validated and decoded appropriately on success.
/// - Returns: a Publisher emitting Output on success, an error otherwise
public func publisher<Output: Decodable>(for request: Request<Output>) -> AnyPublisher<Output, Error> {
dataPublisher(for: request)
.receive(on: config.decodingQueue)
.map { response -> (output: Result<Output, Error>, request: Request<Output>) in
let output = Result {
try self.config.interceptor.adaptOutput(
try self.config.decoder.decode(Output.self, from: response.data),
for: response.request
)
}

return (output: output, request: response.request)
}
.handleEvents(receiveOutput: { self.log($0.output, for: $0.request) })
.tryMap { try $0.output.get() }
.eraseToAnyPublisher()
/// - Returns: a async Output on success, an error otherwise
public func response<Output: Decodable>(for request: Request<Output>) async throws -> Output {
let result = try await dataPublisher(for: request)

do {
let decodedOutput = try config.decoder.decode(Output.self, from: result.data)
let output = try config.interceptor.adaptOutput(decodedOutput, for: result.request)

log(.success(output), for: result.request)
return output
}
catch {
log(.failure(error), for: result.request)
throw error
}
}

/// Return a publisher performing request which has no return value
public func publisher(for request: Request<Void>) -> AnyPublisher<Void, Error> {
dataPublisher(for: request)
.handleEvents(receiveOutput: { self.log(.success(()), for: $0.request) })
.map { _ in () }
.eraseToAnyPublisher()

/// Perform asynchronously `request` which has no return value
public func response(for request: Request<Void>) async throws {
let result = try await dataPublisher(for: request)
log(.success(()), for: result.request)
}
}

extension Session {
private func dataPublisher<Output>(for request: Request<Output>) -> AnyPublisher<Response<Output>, Error> {
private func dataPublisher<Output>(for request: Request<Output>) async throws -> Response<Output> {
let modifiedRequest = config.interceptor.adaptRequest(request)

let urlRequest = try modifiedRequest
.toURLRequest(encoder: config.encoder, relativeTo: baseURL, accepting: config.decoder)

do {
let urlRequest = try modifiedRequest
.toURLRequest(encoder: config.encoder, relativeTo: baseURL, accepting: config.decoder)
let result = try await dataTask(urlRequest)

return urlRequestPublisher(urlRequest)
.validate(config.errorConverter)
.map { Response(data: $0.data, request: modifiedRequest) }
.tryCatch { try self.rescue(error: $0, request: request) }
.handleEvents(receiveCompletion: { self.logIfFailure($0, for: modifiedRequest) })
.eraseToAnyPublisher()
try result.validate(errorDecoder: config.errorConverter)

return Response(data: result.data, request: modifiedRequest)
}
catch {
return Fail(error: error).eraseToAnyPublisher()
}
}

/// log a request completion
private func logIfFailure<Output>(_ completion: Subscribers.Completion<Error>, for request: Request<Output>) {
if case .failure(let error) = completion {
config.interceptor.receivedResponse(.failure(error), for: request)
self.log(.failure(error), for: modifiedRequest)

if try await config.interceptor.shouldRescueRequest(modifiedRequest, error: error) {
return try await dataPublisher(for: modifiedRequest)
}

throw error
}
}

private func log<Output>(_ response: Result<Output, Error>, for request: Request<Output>) {
config.interceptor.receivedResponse(response, for: request)
}

/// try to rescue an error while making a request and retry it when rescue suceeded
private func rescue<Output>(error: Error, request: Request<Output>) throws -> AnyPublisher<Response<Output>, Error> {
guard let rescue = config.interceptor.rescueRequest(request, error: error) else {
throw error
}

return rescue
.map { self.dataPublisher(for: request) }
.switchToLatest()
.eraseToAnyPublisher()
}
}

private struct Response<Output> {
Expand Down
2 changes: 1 addition & 1 deletion Sources/SimpleHTTP/Session/SessionConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public struct SessionConfiguration {
/// To apply multiple interceptors use `ComposeInterceptor`
let interceptor: Interceptor
/// a function decoding data (using `decoder`) as a custom error
private(set) var errorConverter: DataErrorConverter?
private(set) var errorConverter: DataErrorDecoder?

/// - Parameter encoder to use for request bodies
/// - Parameter decoder used to decode http responses
Expand Down
Loading

0 comments on commit f8e4dbe

Please sign in to comment.