diff --git a/Sources/Pinata/Foundation/JSONDecoder+DataDecoder.swift b/Sources/Pinata/Foundation/JSONDecoder+DataDecoder.swift new file mode 100644 index 0000000..a2a9337 --- /dev/null +++ b/Sources/Pinata/Foundation/JSONDecoder+DataDecoder.swift @@ -0,0 +1,5 @@ +import Foundation + +extension JSONDecoder: ContentDataDecoder { + public static let contentType = HTTPContentType.json +} diff --git a/Sources/Pinata/Foundation/URLRequest/URLRequest+HTTPHeader.swift b/Sources/Pinata/Foundation/URLRequest/URLRequest+HTTPHeader.swift index e15d2aa..39b3b5e 100644 --- a/Sources/Pinata/Foundation/URLRequest/URLRequest+HTTPHeader.swift +++ b/Sources/Pinata/Foundation/URLRequest/URLRequest+HTTPHeader.swift @@ -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 + } } diff --git a/Sources/Pinata/Foundation/URLRequest/URLRequest+URL.swift b/Sources/Pinata/Foundation/URLRequest/URLRequest+URL.swift new file mode 100644 index 0000000..0319fd9 --- /dev/null +++ b/Sources/Pinata/Foundation/URLRequest/URLRequest+URL.swift @@ -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 + } +} diff --git a/Sources/Pinata/Foundation/URLSession+Publisher.swift b/Sources/Pinata/Foundation/URLSession+Publisher.swift new file mode 100644 index 0000000..7b8d4db --- /dev/null +++ b/Sources/Pinata/Foundation/URLSession+Publisher.swift @@ -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() + } +} diff --git a/Sources/Pinata/Interceptor/CompositeInterceptor.swift b/Sources/Pinata/Interceptor/CompositeInterceptor.swift new file mode 100644 index 0000000..89cbdb3 --- /dev/null +++ b/Sources/Pinata/Interceptor/CompositeInterceptor.swift @@ -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.Iterator { + interceptors.makeIterator() + } +} + +extension CompositeInterceptor: Interceptor { + public func adaptRequest(_ request: Request) -> Request { + reduce(request) { request, interceptor in + interceptor.adaptRequest(request) + } + } + + public func rescueRequest(_ request: Request, error: Error) -> AnyPublisher? { + let publishers = compactMap { $0.rescueRequest(request, error: error) } + + guard !publishers.isEmpty else { + return nil + } + + return Publishers.MergeMany(publishers).eraseToAnyPublisher() + } + + public func adaptOutput(_ response: Output, for request: Request) throws -> Output { + try reduce(response) { response, interceptor in + try interceptor.adaptOutput(response, for: request) + } + } + + public func receivedResponse(_ result: Result, for request: Request) { + forEach { interceptor in + interceptor.receivedResponse(result, for: request) + } + } +} diff --git a/Sources/Pinata/Interceptor/Interceptor.swift b/Sources/Pinata/Interceptor/Interceptor.swift new file mode 100644 index 0000000..6fef430 --- /dev/null +++ b/Sources/Pinata/Interceptor/Interceptor.swift @@ -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(_ request: Request) -> Request + + /// 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(_ request: Request, error: Error) -> AnyPublisher? +} + +/// 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, for request: Request) throws -> Output + + /// Notify of received response for `request` + /// - Parameter request: the request that was sent to the server + func receivedResponse(_ result: Result, for request: Request) +} diff --git a/Sources/Pinata/Session/Session.swift b/Sources/Pinata/Session/Session.swift new file mode 100644 index 0000000..df02dd6 --- /dev/null +++ b/Sources/Pinata/Session/Session.swift @@ -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 + + 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(for request: Request) -> AnyPublisher { + dataPublisher(for: request) + .receive(on: config.decodingQueue) + .map { response -> (output: Result, request: Request) 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) -> AnyPublisher { + dataPublisher(for: request) + .handleEvents(receiveOutput: { self.log(.success(()), for: $0.request) }) + .map { _ in () } + .eraseToAnyPublisher() + } +} + +extension Session { + private func dataPublisher(for request: Request) -> AnyPublisher, 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(_ completion: Subscribers.Completion, for request: Request) { + if case .failure(let error) = completion { + config.interceptor.receivedResponse(.failure(error), for: request) + } + } + + private func log(_ response: Result, for request: Request) { + config.interceptor.receivedResponse(response, for: request) + } + + /// try to rescue an error while making a request and retry it when rescue suceeded + private func rescue(error: Error, request: Request) throws -> AnyPublisher, 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 { + let data: Data + let request: Request +} diff --git a/Sources/Pinata/Session/SessionConfiguration.swift b/Sources/Pinata/Session/SessionConfiguration.swift new file mode 100644 index 0000000..59559b7 --- /dev/null +++ b/Sources/Pinata/Session/SessionConfiguration.swift @@ -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( + 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) + } + } +} diff --git a/Tests/PinataTests/Foundation/URLRequest/URLRequestTests+URL.swift b/Tests/PinataTests/Foundation/URLRequest/URLRequestTests+URL.swift new file mode 100644 index 0000000..6e16859 --- /dev/null +++ b/Tests/PinataTests/Foundation/URLRequest/URLRequestTests+URL.swift @@ -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") + } +} diff --git a/Tests/PinataTests/Foundation/URLRequest/URLResponseValidateTests.swift b/Tests/PinataTests/Foundation/URLResponseValidateTests.swift similarity index 100% rename from Tests/PinataTests/Foundation/URLRequest/URLResponseValidateTests.swift rename to Tests/PinataTests/Foundation/URLResponseValidateTests.swift diff --git a/Tests/PinataTests/Session/SessionTests.swift b/Tests/PinataTests/Session/SessionTests.swift new file mode 100644 index 0000000..684796c --- /dev/null +++ b/Tests/PinataTests/Session/SessionTests.swift @@ -0,0 +1,177 @@ +import XCTest +import Combine +import Pinata + +class SessionTests: XCTestCase { + let baseURL = URL(string: "https://sessionTests.io")! + let encoder = JSONEncoder() + let decoder = JSONDecoder() + var cancellables: Set = [] + + override func tearDown() { + cancellables.removeAll() + } + + func test_publisherFor_responseIsValid_decodedOutputIsReturned() throws { + let response = Content(value: "response") + let session = sesssionStub() { (data: try! JSONEncoder().encode(response), response: .success) } + let expectation = XCTestExpectation() + + session.publisher(for: Request.test()) + .sink( + receiveCompletion: { _ in }, + receiveValue: { + XCTAssertEqual($0, response) + expectation.fulfill() + } + ) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 1) + } + + func test_publisherFor_responseIsValid_adaptResponseThrow_itReturnAnError() { + let output = Content(value: "adapt throw") + let interceptor = InterceptorStub() + let session = sesssionStub( + interceptor: [interceptor], + data: { (data: try! JSONEncoder().encode(output), response: .success) } + ) + let expectation = XCTestExpectation() + + interceptor.adaptResponseMock = { _, _ in + throw CustomError() + } + + session.publisher(for: .test()) + .sink( + receiveCompletion: { + if case let .failure(error) = $0 { + XCTAssertEqual(error as? CustomError, CustomError()) + } + else { + XCTFail() + } + + expectation.fulfill() + }, + receiveValue: { _ in } + ) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 1) + } + + func test_publisherFor_rescue_rescueIsSuccess_itRetryRequest() { + var isRescued = false + let interceptor = InterceptorStub() + let session = sesssionStub(interceptor: [interceptor]) { + (data: Data(), response: isRescued ? .success : .unauthorized) + } + let expectation = XCTestExpectation() + + interceptor.rescueRequestErrorMock = { _ in + isRescued.toggle() + return Empty(completeImmediately: true).eraseToAnyPublisher() + } + + session.publisher(for: .void()) + .sink( + receiveCompletion: { + XCTAssertTrue(isRescued) + + if case .failure = $0 { + XCTFail("retried request final result should be success") + } + + expectation.fulfill() + }, + receiveValue: { _ in } + ) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 1) + } + + func test_publisherFor_outputIsDecoded_itCallInterceptorReceivedResponse() { + let output = Content(value: "hello") + let interceptor = InterceptorStub() + let session = sesssionStub(interceptor: [interceptor]) { + (data: try! JSONEncoder().encode(output), response: .success) + } + let expectation = XCTestExpectation() + + interceptor.receivedResponseMock = { response, _ in + let response = response as? Result + + XCTAssertEqual(try? response?.get(), output) + expectation.fulfill() + } + + session.publisher(for: .test()) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 1) + } + + /// helper to create a session for testing + private func sesssionStub(interceptor: CompositeInterceptor = [], data: @escaping () -> Session.RequestData) + -> Session { + let config = SessionConfiguration(encoder: encoder, decoder: decoder, interceptors: interceptor) + + return Session(baseURL: baseURL, configuration: config, dataPublisher: { _ in + Just(data()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + }) + } +} + +private enum Endpoint: String, Path { + case test +} + +private struct Content: Codable, Equatable { + let value: String +} + +private struct CustomError: Error, Equatable { + +} + +private extension Request { + static func test() -> Self where Output == Content { + .get(Endpoint.test) + } + + static func void() -> Self where Output == Void { + .get(Endpoint.test) + } +} + +private class InterceptorStub: Interceptor { + var rescueRequestErrorMock: ((Error) -> AnyPublisher?)? + var receivedResponseMock: ((Any, Any) -> ())? + var adaptResponseMock: ((Any, Any) throws -> Any)? + + func adaptRequest(_ request: Request) -> Request { + request + } + + func rescueRequest(_ request: Request, error: Error) -> AnyPublisher? { + rescueRequestErrorMock?(error) + } + + func adaptOutput(_ output: Output, for request: Request) throws -> Output { + guard let mock = adaptResponseMock else { + return output + } + + return try mock(output, request) as! Output + } + + func receivedResponse(_ result: Result, for request: Request) { + receivedResponseMock?(result, request) + } +}