Skip to content

Commit a90d5f1

Browse files
authored
feat(session): Session object to perform requests (#3)
1 parent 594c2c4 commit a90d5f1

File tree

11 files changed

+496
-0
lines changed

11 files changed

+496
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Foundation
2+
3+
extension JSONDecoder: ContentDataDecoder {
4+
public static let contentType = HTTPContentType.json
5+
}

Sources/Pinata/Foundation/URLRequest/URLRequest+HTTPHeader.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,12 @@ extension URLRequest {
1010
setValue(value, forHTTPHeaderField: header.key)
1111
}
1212
}
13+
14+
public func settingHeaders(_ headers: HTTPHeaderFields) -> Self {
15+
var urlRequest = self
16+
17+
urlRequest.setHeaders(headers)
18+
19+
return urlRequest
20+
}
1321
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Foundation
2+
3+
extension URLRequest {
4+
/// Return a new URLRequest whose endpoint is relative to `baseURL`
5+
func relativeTo(_ baseURL: URL) -> URLRequest {
6+
var urlRequest = self
7+
var components = URLComponents(string: baseURL.appendingPathComponent(url?.path ?? "").absoluteString)
8+
9+
components?.percentEncodedQuery = url?.query
10+
11+
urlRequest.url = components?.url
12+
13+
return urlRequest
14+
}
15+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import Foundation
2+
3+
extension URLSession {
4+
/// Return a dataTaskPublisher as a `DataPublisher`
5+
public func dataPublisher(for request: URLRequest) -> Session.RequestDataPublisher {
6+
dataTaskPublisher(for: request)
7+
.mapError { $0 as Error }
8+
.eraseToAnyPublisher()
9+
}
10+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Foundation
2+
import Combine
3+
4+
/// Use an Array of `Interceptor` as a single `Interceptor`
5+
public struct CompositeInterceptor: ExpressibleByArrayLiteral, Sequence {
6+
let interceptors: [Interceptor]
7+
8+
public init(arrayLiteral interceptors: Interceptor...) {
9+
self.interceptors = interceptors
10+
}
11+
12+
public func makeIterator() -> Array<Interceptor>.Iterator {
13+
interceptors.makeIterator()
14+
}
15+
}
16+
17+
extension CompositeInterceptor: Interceptor {
18+
public func adaptRequest<Output>(_ request: Request<Output>) -> Request<Output> {
19+
reduce(request) { request, interceptor in
20+
interceptor.adaptRequest(request)
21+
}
22+
}
23+
24+
public func rescueRequest<Output>(_ request: Request<Output>, error: Error) -> AnyPublisher<Void, Error>? {
25+
let publishers = compactMap { $0.rescueRequest(request, error: error) }
26+
27+
guard !publishers.isEmpty else {
28+
return nil
29+
}
30+
31+
return Publishers.MergeMany(publishers).eraseToAnyPublisher()
32+
}
33+
34+
public func adaptOutput<Output>(_ response: Output, for request: Request<Output>) throws -> Output {
35+
try reduce(response) { response, interceptor in
36+
try interceptor.adaptOutput(response, for: request)
37+
}
38+
}
39+
40+
public func receivedResponse<Output>(_ result: Result<Output, Error>, for request: Request<Output>) {
41+
forEach { interceptor in
42+
interceptor.receivedResponse(result, for: request)
43+
}
44+
}
45+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Foundation
2+
import Combine
3+
4+
public typealias Interceptor = RequestInterceptor & ResponseInterceptor
5+
6+
/// a protocol intercepting a session request
7+
public protocol RequestInterceptor {
8+
/// Should be called before making the request to provide modifications to `request`
9+
func adaptRequest<Output>(_ request: Request<Output>) -> Request<Output>
10+
11+
/// catch and retry a failed request
12+
/// - Returns: nil if the request should not be retried. Otherwise a publisher that will be executed before
13+
/// retrying the request
14+
func rescueRequest<Output>(_ request: Request<Output>, error: Error) -> AnyPublisher<Void, Error>?
15+
}
16+
17+
/// a protocol intercepting a session response
18+
public protocol ResponseInterceptor {
19+
/// Should be called once the request is done and output was received. Let one last chance to modify the output
20+
/// optionally throwing an error instead if needed
21+
/// - Parameter request: the request that was sent to the server
22+
func adaptOutput<Output>(_ output: Output, for request: Request<Output>) throws -> Output
23+
24+
/// Notify of received response for `request`
25+
/// - Parameter request: the request that was sent to the server
26+
func receivedResponse<Output>(_ result: Result<Output, Error>, for request: Request<Output>)
27+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import Foundation
2+
import Combine
3+
4+
/// Primary class of the library used to perform http request using `Request` objects
5+
public class Session {
6+
/// Data returned by a http request
7+
public typealias RequestData = URLSession.DataTaskPublisher.Output
8+
9+
/// a Publisher emitting `RequestData`
10+
public typealias RequestDataPublisher = AnyPublisher<RequestData, Error>
11+
12+
let baseURL: URL
13+
let config: SessionConfiguration
14+
/// a closure returning a publisher based for a given `URLRequest`
15+
let urlRequestPublisher: (URLRequest) -> RequestDataPublisher
16+
17+
/// init the class using a `URLSession` instance
18+
/// - Parameter baseURL: common url for all the requests. Allow to switch environments easily
19+
/// - Parameter configuration: session configuration to use
20+
/// - Parameter urlSession: `URLSession` instance to use to make requests.
21+
public convenience init(baseURL: URL, configuration: SessionConfiguration = .init(), urlSession: URLSession) {
22+
self.init(
23+
baseURL: baseURL,
24+
configuration: configuration,
25+
dataPublisher: urlSession.dataPublisher(for:)
26+
)
27+
}
28+
29+
/// init the class with a base url for request
30+
/// - Parameter baseURL: common url for all the requests. Allow to switch environments easily
31+
/// - Parameter configuration: session configuration to use
32+
/// - Parameter dataPublisher: publisher used by the class to make http requests. If none provided it default
33+
/// to `URLSession.dataPublisher(for:)`
34+
public init(
35+
baseURL: URL,
36+
configuration: SessionConfiguration = SessionConfiguration(),
37+
dataPublisher: @escaping (URLRequest) -> RequestDataPublisher = { URLSession.shared.dataPublisher(for: $0) }
38+
) {
39+
self.baseURL = baseURL
40+
self.config = configuration
41+
self.urlRequestPublisher = dataPublisher
42+
}
43+
44+
/// Return a publisher performing request and returning `Output` data
45+
///
46+
/// The request is validated and decoded appropriately on success.
47+
/// - Returns: a Publisher emitting Output on success, an error otherwise
48+
public func publisher<Output: Decodable>(for request: Request<Output>) -> AnyPublisher<Output, Error> {
49+
dataPublisher(for: request)
50+
.receive(on: config.decodingQueue)
51+
.map { response -> (output: Result<Output, Error>, request: Request<Output>) in
52+
let output = Result {
53+
try self.config.interceptor.adaptOutput(
54+
try self.config.decoder.decode(Output.self, from: response.data),
55+
for: response.request
56+
)
57+
}
58+
59+
return (output: output, request: response.request)
60+
}
61+
.handleEvents(receiveOutput: { self.log($0.output, for: $0.request) })
62+
.tryMap { try $0.output.get() }
63+
.eraseToAnyPublisher()
64+
}
65+
66+
/// Return a publisher performing request which has no return value
67+
public func publisher(for request: Request<Void>) -> AnyPublisher<Void, Error> {
68+
dataPublisher(for: request)
69+
.handleEvents(receiveOutput: { self.log(.success(()), for: $0.request) })
70+
.map { _ in () }
71+
.eraseToAnyPublisher()
72+
}
73+
}
74+
75+
extension Session {
76+
private func dataPublisher<Output>(for request: Request<Output>) -> AnyPublisher<Response<Output>, Error> {
77+
let adaptedRequest = config.interceptor.adaptRequest(request)
78+
79+
do {
80+
let urlRequest = try adaptedRequest
81+
.toURLRequest(encoder: config.encoder)
82+
.relativeTo(baseURL)
83+
.settingHeaders([.accept: type(of: config.decoder).contentType.value])
84+
85+
return urlRequestPublisher(urlRequest)
86+
.validate(config.errorConverter)
87+
.map { Response(data: $0.data, request: adaptedRequest) }
88+
.handleEvents(receiveCompletion: { self.logIfFailure($0, for: adaptedRequest) })
89+
.tryCatch { try self.rescue(error: $0, request: request) }
90+
.eraseToAnyPublisher()
91+
}
92+
catch {
93+
return Fail(error: error).eraseToAnyPublisher()
94+
}
95+
}
96+
97+
/// log a request completion
98+
private func logIfFailure<Output>(_ completion: Subscribers.Completion<Error>, for request: Request<Output>) {
99+
if case .failure(let error) = completion {
100+
config.interceptor.receivedResponse(.failure(error), for: request)
101+
}
102+
}
103+
104+
private func log<Output>(_ response: Result<Output, Error>, for request: Request<Output>) {
105+
config.interceptor.receivedResponse(response, for: request)
106+
}
107+
108+
/// try to rescue an error while making a request and retry it when rescue suceeded
109+
private func rescue<Output>(error: Error, request: Request<Output>) throws -> AnyPublisher<Response<Output>, Error> {
110+
guard let rescue = config.interceptor.rescueRequest(request, error: error) else {
111+
throw error
112+
}
113+
114+
return rescue
115+
.map { self.dataPublisher(for: request) }
116+
.switchToLatest()
117+
.eraseToAnyPublisher()
118+
}
119+
}
120+
121+
private struct Response<Output> {
122+
let data: Data
123+
let request: Request<Output>
124+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Foundation
2+
3+
/// a type defining some parameters for a `Session`
4+
public struct SessionConfiguration {
5+
/// encoder to use for request bodies
6+
let encoder: ContentDataEncoder
7+
/// decoder used to decode http responses
8+
let decoder: ContentDataDecoder
9+
/// queue on which to decode data
10+
let decodingQueue: DispatchQueue
11+
/// an interceptor to apply custom behavior on the session requests/responses.
12+
/// To apply multiple interceptors use `ComposeInterceptor`
13+
let interceptor: Interceptor
14+
/// a function decoding data (using `decoder`) as a custom error
15+
private(set) var errorConverter: DataErrorConverter?
16+
17+
/// - Parameter encoder to use for request bodies
18+
/// - Parameter decoder used to decode http responses
19+
/// - Parameter decodeQueue: queue on which to decode data
20+
/// - Parameter interceptors: interceptor list to apply on the session requests/responses
21+
public init(
22+
encoder: ContentDataEncoder = JSONEncoder(),
23+
decoder: ContentDataDecoder = JSONDecoder(),
24+
decodingQueue: DispatchQueue = .main,
25+
interceptors: CompositeInterceptor = []) {
26+
self.encoder = encoder
27+
self.decoder = decoder
28+
self.decodingQueue = decodingQueue
29+
self.interceptor = interceptors
30+
}
31+
32+
/// - Parameter dataError: Error type to use when having error with data
33+
public init<DataError: Error & Decodable>(
34+
encoder: ContentDataEncoder = JSONEncoder(),
35+
decoder: ContentDataDecoder = JSONDecoder(),
36+
decodingQueue: DispatchQueue = .main,
37+
interceptors: CompositeInterceptor = [],
38+
dataError: DataError.Type
39+
) {
40+
self.init(encoder: encoder, decoder: decoder, decodingQueue: decodingQueue, interceptors: interceptors)
41+
self.errorConverter = {
42+
try decoder.decode(dataError, from: $0)
43+
}
44+
}
45+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Foundation
2+
import XCTest
3+
@testable import Pinata
4+
5+
class URLRequestURLTests: XCTestCase {
6+
func test_relativeTo_requestURLHasBaseURL() {
7+
let request = URLRequest(url: URL(string: "path")!)
8+
let url = request.relativeTo(URL(string: "https://google.com")!).url
9+
10+
XCTAssertEqual(url?.absoluteString, "https://google.com/path")
11+
}
12+
13+
func test_relativeTo_urlStartWithSlash_requestPathContainBothPaths() {
14+
let request = URLRequest(url: URL(string: "/path")!)
15+
let url = request.relativeTo(URL(string: "https://google.com/lostAndFound")!).url
16+
17+
XCTAssertEqual(url?.absoluteString, "https://google.com/lostAndFound/path")
18+
}
19+
20+
func test_relativeTo_baseURLHasPath_requestContainBaseURLPath() {
21+
let request = URLRequest(url: URL(string: "concatenated")!)
22+
let url = request.relativeTo(URL(string: "https://google.com/firstPath")!).url
23+
24+
XCTAssertEqual(url?.absoluteString, "https://google.com/firstPath/concatenated")
25+
}
26+
27+
func test_relativeTo_baseURLHasQuery_requestHasNoQuery() {
28+
let request = URLRequest(url: URL(string: "concatenated")!)
29+
let url = request.relativeTo(URL(string: "https://google.com?param=1")!).url
30+
31+
XCTAssertEqual(url?.absoluteString, "https://google.com/concatenated")
32+
}
33+
34+
func test_relativeTo_urlHasQuery_requestHasQuery() {
35+
let request = URLRequest(url: URL(string: "concatenated?toKeep=1")!)
36+
let url = request.relativeTo(URL(string: "https://google.com?param=1")!).url
37+
38+
XCTAssertEqual(url?.absoluteString, "https://google.com/concatenated?toKeep=1")
39+
}
40+
}

Tests/PinataTests/Foundation/URLRequest/URLResponseValidateTests.swift renamed to Tests/PinataTests/Foundation/URLResponseValidateTests.swift

File renamed without changes.

0 commit comments

Comments
 (0)