Skip to content

Commit

Permalink
feat(session): Session object to perform requests (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
pjechris authored Feb 3, 2022
1 parent 594c2c4 commit a90d5f1
Show file tree
Hide file tree
Showing 11 changed files with 496 additions and 0 deletions.
5 changes: 5 additions & 0 deletions Sources/Pinata/Foundation/JSONDecoder+DataDecoder.swift
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,12 @@ extension URLRequest {
setValue(value, forHTTPHeaderField: header.key)
}
}

public func settingHeaders(_ headers: HTTPHeaderFields) -> Self {
var urlRequest = self

urlRequest.setHeaders(headers)

return urlRequest
}
}
15 changes: 15 additions & 0 deletions Sources/Pinata/Foundation/URLRequest/URLRequest+URL.swift
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
}
}
10 changes: 10 additions & 0 deletions Sources/Pinata/Foundation/URLSession+Publisher.swift
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()
}
}
45 changes: 45 additions & 0 deletions Sources/Pinata/Interceptor/CompositeInterceptor.swift
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)
}
}
}
27 changes: 27 additions & 0 deletions Sources/Pinata/Interceptor/Interceptor.swift
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>)
}
124 changes: 124 additions & 0 deletions Sources/Pinata/Session/Session.swift
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>
}
45 changes: 45 additions & 0 deletions Sources/Pinata/Session/SessionConfiguration.swift
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 Tests/PinataTests/Foundation/URLRequest/URLRequestTests+URL.swift
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")
}
}
Loading

0 comments on commit a90d5f1

Please sign in to comment.