-
-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(session): Session object to perform requests (#3)
- Loading branch information
Showing
11 changed files
with
496 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import Foundation | ||
|
||
extension JSONDecoder: ContentDataDecoder { | ||
public static let contentType = HTTPContentType.json | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import Foundation | ||
|
||
extension URLRequest { | ||
/// Return a new URLRequest whose endpoint is relative to `baseURL` | ||
func relativeTo(_ baseURL: URL) -> URLRequest { | ||
var urlRequest = self | ||
var components = URLComponents(string: baseURL.appendingPathComponent(url?.path ?? "").absoluteString) | ||
|
||
components?.percentEncodedQuery = url?.query | ||
|
||
urlRequest.url = components?.url | ||
|
||
return urlRequest | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
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() | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import Foundation | ||
import Combine | ||
|
||
/// Use an Array of `Interceptor` as a single `Interceptor` | ||
public struct CompositeInterceptor: ExpressibleByArrayLiteral, Sequence { | ||
let interceptors: [Interceptor] | ||
|
||
public init(arrayLiteral interceptors: Interceptor...) { | ||
self.interceptors = interceptors | ||
} | ||
|
||
public func makeIterator() -> Array<Interceptor>.Iterator { | ||
interceptors.makeIterator() | ||
} | ||
} | ||
|
||
extension CompositeInterceptor: Interceptor { | ||
public func adaptRequest<Output>(_ request: Request<Output>) -> Request<Output> { | ||
reduce(request) { request, interceptor in | ||
interceptor.adaptRequest(request) | ||
} | ||
} | ||
|
||
public func rescueRequest<Output>(_ request: Request<Output>, error: Error) -> AnyPublisher<Void, Error>? { | ||
let publishers = compactMap { $0.rescueRequest(request, error: error) } | ||
|
||
guard !publishers.isEmpty else { | ||
return nil | ||
} | ||
|
||
return Publishers.MergeMany(publishers).eraseToAnyPublisher() | ||
} | ||
|
||
public func adaptOutput<Output>(_ response: Output, for request: Request<Output>) throws -> Output { | ||
try reduce(response) { response, interceptor in | ||
try interceptor.adaptOutput(response, for: request) | ||
} | ||
} | ||
|
||
public func receivedResponse<Output>(_ result: Result<Output, Error>, for request: Request<Output>) { | ||
forEach { interceptor in | ||
interceptor.receivedResponse(result, for: request) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import Foundation | ||
import Combine | ||
|
||
public typealias Interceptor = RequestInterceptor & ResponseInterceptor | ||
|
||
/// a protocol intercepting a session request | ||
public protocol RequestInterceptor { | ||
/// Should be called before making the request to provide modifications to `request` | ||
func adaptRequest<Output>(_ request: Request<Output>) -> Request<Output> | ||
|
||
/// catch and retry a failed request | ||
/// - Returns: nil if the request should not be retried. Otherwise a publisher that will be executed before | ||
/// retrying the request | ||
func rescueRequest<Output>(_ request: Request<Output>, error: Error) -> AnyPublisher<Void, Error>? | ||
} | ||
|
||
/// a protocol intercepting a session response | ||
public protocol ResponseInterceptor { | ||
/// Should be called once the request is done and output was received. Let one last chance to modify the output | ||
/// optionally throwing an error instead if needed | ||
/// - Parameter request: the request that was sent to the server | ||
func adaptOutput<Output>(_ output: Output, for request: Request<Output>) throws -> Output | ||
|
||
/// Notify of received response for `request` | ||
/// - Parameter request: the request that was sent to the server | ||
func receivedResponse<Output>(_ result: Result<Output, Error>, for request: Request<Output>) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import Foundation | ||
import Combine | ||
|
||
/// Primary class of the library used to perform http request using `Request` objects | ||
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> | ||
|
||
let baseURL: URL | ||
let config: SessionConfiguration | ||
/// a closure returning a publisher based for a given `URLRequest` | ||
let urlRequestPublisher: (URLRequest) -> RequestDataPublisher | ||
|
||
/// 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:) | ||
) | ||
} | ||
|
||
/// 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 | ||
/// to `URLSession.dataPublisher(for:)` | ||
public init( | ||
baseURL: URL, | ||
configuration: SessionConfiguration = SessionConfiguration(), | ||
dataPublisher: @escaping (URLRequest) -> RequestDataPublisher = { URLSession.shared.dataPublisher(for: $0) } | ||
) { | ||
self.baseURL = baseURL | ||
self.config = configuration | ||
self.urlRequestPublisher = dataPublisher | ||
} | ||
|
||
/// 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> { | ||
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() | ||
} | ||
|
||
/// 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() | ||
} | ||
} | ||
|
||
extension Session { | ||
private func dataPublisher<Output>(for request: Request<Output>) -> AnyPublisher<Response<Output>, Error> { | ||
let adaptedRequest = config.interceptor.adaptRequest(request) | ||
|
||
do { | ||
let urlRequest = try adaptedRequest | ||
.toURLRequest(encoder: config.encoder) | ||
.relativeTo(baseURL) | ||
.settingHeaders([.accept: type(of: config.decoder).contentType.value]) | ||
|
||
return urlRequestPublisher(urlRequest) | ||
.validate(config.errorConverter) | ||
.map { Response(data: $0.data, request: adaptedRequest) } | ||
.handleEvents(receiveCompletion: { self.logIfFailure($0, for: adaptedRequest) }) | ||
.tryCatch { try self.rescue(error: $0, request: request) } | ||
.eraseToAnyPublisher() | ||
} | ||
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) | ||
} | ||
} | ||
|
||
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> { | ||
let data: Data | ||
let request: Request<Output> | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import Foundation | ||
|
||
/// a type defining some parameters for a `Session` | ||
public struct SessionConfiguration { | ||
/// encoder to use for request bodies | ||
let encoder: ContentDataEncoder | ||
/// decoder used to decode http responses | ||
let decoder: ContentDataDecoder | ||
/// queue on which to decode data | ||
let decodingQueue: DispatchQueue | ||
/// an interceptor to apply custom behavior on the session requests/responses. | ||
/// To apply multiple interceptors use `ComposeInterceptor` | ||
let interceptor: Interceptor | ||
/// a function decoding data (using `decoder`) as a custom error | ||
private(set) var errorConverter: DataErrorConverter? | ||
|
||
/// - Parameter encoder to use for request bodies | ||
/// - Parameter decoder used to decode http responses | ||
/// - Parameter decodeQueue: queue on which to decode data | ||
/// - Parameter interceptors: interceptor list to apply on the session requests/responses | ||
public init( | ||
encoder: ContentDataEncoder = JSONEncoder(), | ||
decoder: ContentDataDecoder = JSONDecoder(), | ||
decodingQueue: DispatchQueue = .main, | ||
interceptors: CompositeInterceptor = []) { | ||
self.encoder = encoder | ||
self.decoder = decoder | ||
self.decodingQueue = decodingQueue | ||
self.interceptor = interceptors | ||
} | ||
|
||
/// - Parameter dataError: Error type to use when having error with data | ||
public init<DataError: Error & Decodable>( | ||
encoder: ContentDataEncoder = JSONEncoder(), | ||
decoder: ContentDataDecoder = JSONDecoder(), | ||
decodingQueue: DispatchQueue = .main, | ||
interceptors: CompositeInterceptor = [], | ||
dataError: DataError.Type | ||
) { | ||
self.init(encoder: encoder, decoder: decoder, decodingQueue: decodingQueue, interceptors: interceptors) | ||
self.errorConverter = { | ||
try decoder.decode(dataError, from: $0) | ||
} | ||
} | ||
} |
40 changes: 40 additions & 0 deletions
40
Tests/PinataTests/Foundation/URLRequest/URLRequestTests+URL.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import Foundation | ||
import XCTest | ||
@testable import Pinata | ||
|
||
class URLRequestURLTests: XCTestCase { | ||
func test_relativeTo_requestURLHasBaseURL() { | ||
let request = URLRequest(url: URL(string: "path")!) | ||
let url = request.relativeTo(URL(string: "https://google.com")!).url | ||
|
||
XCTAssertEqual(url?.absoluteString, "https://google.com/path") | ||
} | ||
|
||
func test_relativeTo_urlStartWithSlash_requestPathContainBothPaths() { | ||
let request = URLRequest(url: URL(string: "/path")!) | ||
let url = request.relativeTo(URL(string: "https://google.com/lostAndFound")!).url | ||
|
||
XCTAssertEqual(url?.absoluteString, "https://google.com/lostAndFound/path") | ||
} | ||
|
||
func test_relativeTo_baseURLHasPath_requestContainBaseURLPath() { | ||
let request = URLRequest(url: URL(string: "concatenated")!) | ||
let url = request.relativeTo(URL(string: "https://google.com/firstPath")!).url | ||
|
||
XCTAssertEqual(url?.absoluteString, "https://google.com/firstPath/concatenated") | ||
} | ||
|
||
func test_relativeTo_baseURLHasQuery_requestHasNoQuery() { | ||
let request = URLRequest(url: URL(string: "concatenated")!) | ||
let url = request.relativeTo(URL(string: "https://google.com?param=1")!).url | ||
|
||
XCTAssertEqual(url?.absoluteString, "https://google.com/concatenated") | ||
} | ||
|
||
func test_relativeTo_urlHasQuery_requestHasQuery() { | ||
let request = URLRequest(url: URL(string: "concatenated?toKeep=1")!) | ||
let url = request.relativeTo(URL(string: "https://google.com?param=1")!).url | ||
|
||
XCTAssertEqual(url?.absoluteString, "https://google.com/concatenated?toKeep=1") | ||
} | ||
} |
File renamed without changes.
Oops, something went wrong.