From 6ec589c72aab76689043440e04977ab154893d41 Mon Sep 17 00:00:00 2001 From: pjechris Date: Sun, 24 Apr 2022 12:30:21 +0200 Subject: [PATCH] feat(session): Async/await support (#4) --- README.md | 5 + .../Foundation/Publisher+Validate.swift | 2 +- .../URLRequest/URLRequest+Encode.swift | 6 +- .../URLRequest/URLRequest+HTTPHeader.swift | 2 + .../URLRequest/URLRequest+URL.swift | 2 +- .../Foundation/URLResponse+Validate.swift | 13 ++- .../SimpleHTTP/Interceptor/Interceptor.swift | 30 ++--- .../Request/Request+URLRequest.swift | 19 +++- .../SimpleHTTP/Session/Session+Async.swift | 39 +++++++ Sources/SimpleHTTP/Session/Session.swift | 4 +- .../Request/RequestTests.swift | 21 +++- .../Session/SessionTests.swift | 105 +++++++----------- 12 files changed, 150 insertions(+), 98 deletions(-) create mode 100644 Sources/SimpleHTTP/Session/Session+Async.swift diff --git a/README.md b/README.md index 0c0000b..a37623c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # SimpleHTTP +![swift](https://img.shields.io/badge/Swift-5.5%2B-orange?logo=swift&logoColor=white) +![platforms](https://img.shields.io/badge/Platforms-iOS%20%7C%20macOS-lightgrey) +![tests](https://github.com/pjechris/SimpleHTTP/actions/workflows/test.yml/badge.svg) +[![twitter](https://img.shields.io/badge/twitter-pjechris-1DA1F2?logo=twitter&logoColor=white)](https://twitter.com/pjechris) + Simple declarative HTTP API framework ## Basic Usage diff --git a/Sources/SimpleHTTP/Foundation/Publisher+Validate.swift b/Sources/SimpleHTTP/Foundation/Publisher+Validate.swift index f71f940..b544a68 100644 --- a/Sources/SimpleHTTP/Foundation/Publisher+Validate.swift +++ b/Sources/SimpleHTTP/Foundation/Publisher+Validate.swift @@ -13,7 +13,7 @@ extension Publisher where Output == URLSession.DataTaskPublisher.Output { public func validate(_ converter: DataErrorConverter? = nil) -> AnyPublisher { tryMap { output in do { - try (output.response as? HTTPURLResponse)?.validate() + try output.response.validate() return output } catch { diff --git a/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+Encode.swift b/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+Encode.swift index 91fde6a..39243e0 100644 --- a/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+Encode.swift +++ b/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+Encode.swift @@ -4,8 +4,8 @@ import Foundation import FoundationNetworking #endif -public extension URLRequest { - func encodedBody(_ body: Encodable, encoder: ContentDataEncoder) throws -> Self { +extension URLRequest { + public func encodedBody(_ body: Encodable, encoder: ContentDataEncoder) throws -> Self { var request = self try request.encodeBody(body, encoder: encoder) @@ -14,7 +14,7 @@ public extension URLRequest { } /// Use a `Encodable` object as request body and set the "Content-Type" header associated to the encoder - mutating func encodeBody(_ body: Encodable, encoder: ContentDataEncoder) throws { + public mutating func encodeBody(_ body: Encodable, encoder: ContentDataEncoder) throws { httpBody = try body.encoded(with: encoder) setHeaders([.contentType: type(of: encoder).contentType.value]) } diff --git a/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+HTTPHeader.swift b/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+HTTPHeader.swift index 39b3b5e..5c9a4c5 100644 --- a/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+HTTPHeader.swift +++ b/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+HTTPHeader.swift @@ -5,12 +5,14 @@ import FoundationNetworking #endif extension URLRequest { + /// Set the headers on the request public mutating func setHeaders(_ headers: HTTPHeaderFields) { for (header, value) in headers { setValue(value, forHTTPHeaderField: header.key) } } + /// Return a new `URLRequest`` with added `headers`` public func settingHeaders(_ headers: HTTPHeaderFields) -> Self { var urlRequest = self diff --git a/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+URL.swift b/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+URL.swift index 0319fd9..47e087b 100644 --- a/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+URL.swift +++ b/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+URL.swift @@ -2,7 +2,7 @@ import Foundation extension URLRequest { /// Return a new URLRequest whose endpoint is relative to `baseURL` - func relativeTo(_ baseURL: URL) -> URLRequest { + public func relativeTo(_ baseURL: URL) -> URLRequest { var urlRequest = self var components = URLComponents(string: baseURL.appendingPathComponent(url?.path ?? "").absoluteString) diff --git a/Sources/SimpleHTTP/Foundation/URLResponse+Validate.swift b/Sources/SimpleHTTP/Foundation/URLResponse+Validate.swift index 612849c..801ef49 100644 --- a/Sources/SimpleHTTP/Foundation/URLResponse+Validate.swift +++ b/Sources/SimpleHTTP/Foundation/URLResponse+Validate.swift @@ -4,9 +4,18 @@ import Foundation import FoundationNetworking #endif -extension HTTPURLResponse { - /// check whether a response is valid or not +extension URLResponse { + /// Validate when self is of type `HTTPURLResponse` public func validate() throws { + if let response = self as? HTTPURLResponse { + try response.validateStatusCode() + } + } +} + +extension HTTPURLResponse { + /// Throw an error when response status code is not Success (2xx) + func validateStatusCode() throws { guard (200..<300).contains(statusCode) else { throw HTTPError(statusCode: statusCode) } diff --git a/Sources/SimpleHTTP/Interceptor/Interceptor.swift b/Sources/SimpleHTTP/Interceptor/Interceptor.swift index 6fef430..45bd57e 100644 --- a/Sources/SimpleHTTP/Interceptor/Interceptor.swift +++ b/Sources/SimpleHTTP/Interceptor/Interceptor.swift @@ -5,23 +5,23 @@ 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? + /// 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) + /// 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/SimpleHTTP/Request/Request+URLRequest.swift b/Sources/SimpleHTTP/Request/Request+URLRequest.swift index 02783e3..c72a0bf 100644 --- a/Sources/SimpleHTTP/Request/Request+URLRequest.swift +++ b/Sources/SimpleHTTP/Request/Request+URLRequest.swift @@ -5,7 +5,22 @@ import FoundationNetworking #endif extension Request { - func toURLRequest(encoder: ContentDataEncoder) throws -> URLRequest { + /// Transform a Request into a URLRequest + /// - Parameter encoder: the encoder to use to encode the body is present + /// - Parameter relativeTo: the base URL to append to the request path + /// - Parameter accepting: if not nil will be used to set "Accept" header value + public func toURLRequest(encoder: ContentDataEncoder, relativeTo baseURL: URL, accepting: ContentDataDecoder? = nil) throws -> URLRequest { + let request = try toURLRequest(encoder: encoder) + .relativeTo(baseURL) + + if let decoder = accepting { + return request.settingHeaders([.accept: type(of: decoder).contentType.value]) + } + + return request + } + + private func toURLRequest(encoder: ContentDataEncoder) throws -> URLRequest { var urlRequest = try URLRequest(url: URL(from: self)) urlRequest.httpMethod = method.rawValue.uppercased() @@ -17,5 +32,7 @@ extension Request { return urlRequest } + + } diff --git a/Sources/SimpleHTTP/Session/Session+Async.swift b/Sources/SimpleHTTP/Session/Session+Async.swift new file mode 100644 index 0000000..3a222bf --- /dev/null +++ b/Sources/SimpleHTTP/Session/Session+Async.swift @@ -0,0 +1,39 @@ +import Foundation +import Combine + +#if canImport(_Concurrency) + +extension Session { + public func response(for request: Request) async throws -> Output { + try await response(publisher: publisher(for: request)) + } + + public func response(for request: Request) async throws { + try await response(publisher: publisher(for: request)) + } + + private func response(publisher: AnyPublisher) async throws -> Output { + var cancellable: Set = [] + let onCancel = { cancellable.removeAll() } + + return try await withTaskCancellationHandler( + handler: { onCancel() }, + operation: { + try await withCheckedThrowingContinuation { continuation in + publisher + .sink( + receiveCompletion: { + if case let .failure(error) = $0 { + return continuation.resume(throwing: error) + } + }, + receiveValue: { + continuation.resume(returning: $0) + }) + .store(in: &cancellable) + } + }) + } +} + +#endif diff --git a/Sources/SimpleHTTP/Session/Session.swift b/Sources/SimpleHTTP/Session/Session.swift index df02dd6..9361b66 100644 --- a/Sources/SimpleHTTP/Session/Session.swift +++ b/Sources/SimpleHTTP/Session/Session.swift @@ -78,9 +78,7 @@ extension Session { do { let urlRequest = try adaptedRequest - .toURLRequest(encoder: config.encoder) - .relativeTo(baseURL) - .settingHeaders([.accept: type(of: config.decoder).contentType.value]) + .toURLRequest(encoder: config.encoder, relativeTo: baseURL, accepting: config.decoder) return urlRequestPublisher(urlRequest) .validate(config.errorConverter) diff --git a/Tests/SimpleHTTPTests/Request/RequestTests.swift b/Tests/SimpleHTTPTests/Request/RequestTests.swift index 5756658..14f86c2 100644 --- a/Tests/SimpleHTTPTests/Request/RequestTests.swift +++ b/Tests/SimpleHTTPTests/Request/RequestTests.swift @@ -6,31 +6,40 @@ class RequestTests: XCTestCase { case test } + let baseURL = URL(string: "https://google.fr")! + func test_init_withPathAsString() { XCTAssertEqual(Request.get("hello_world").path, "hello_world") } - func test_toURLRequest_itSetHttpMethod() throws { + func test_toURLRequest_setHttpMethod() throws { let request = try Request.post(TestEndpoint.test, body: nil) - .toURLRequest(encoder: JSONEncoder()) + .toURLRequest(encoder: JSONEncoder(), relativeTo: baseURL) XCTAssertEqual(request.httpMethod, "POST") } - func test_toURLRequest_itEncodeBody() throws { + func test_toURLRequest_encodeBody() throws { let request = try Request.post(TestEndpoint.test, body: Body()) - .toURLRequest(encoder: JSONEncoder()) + .toURLRequest(encoder: JSONEncoder(), relativeTo: baseURL) XCTAssertEqual(request.httpBody, try JSONEncoder().encode(Body())) } - func test_toURLRequest_itFillDefaultHeaders() throws { + func test_toURLRequest_fillDefaultHeaders() throws { let request = try Request.post(TestEndpoint.test, body: Body()) - .toURLRequest(encoder: JSONEncoder()) + .toURLRequest(encoder: JSONEncoder(), relativeTo: baseURL) XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], "application/json") } + func test_toURLRequest_absoluteStringIsBaseURLPlusPath() throws { + let request = try Request.get(TestEndpoint.test) + .toURLRequest(encoder: JSONEncoder(), relativeTo: baseURL) + + XCTAssertEqual(request.url?.absoluteString, baseURL.absoluteString + "/test") + } + } private struct Body: Encodable { diff --git a/Tests/SimpleHTTPTests/Session/SessionTests.swift b/Tests/SimpleHTTPTests/Session/SessionTests.swift index 026080c..f99b88a 100644 --- a/Tests/SimpleHTTPTests/Session/SessionTests.swift +++ b/Tests/SimpleHTTPTests/Session/SessionTests.swift @@ -2,117 +2,90 @@ import XCTest import Combine import SimpleHTTP -class SessionTests: XCTestCase { +class SessionAsyncTests: 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) + func test_response_responseIsValid_decodedOutputIsReturned() async throws { + let expectedResponse = Content(value: "response") + let session = sesssionStub() { (data: try! JSONEncoder().encode(expectedResponse), response: .success) } + let response = try await session.response(for: Request.test()) - wait(for: [expectation], timeout: 1) + XCTAssertEqual(response, expectedResponse) } - func test_publisherFor_responseIsValid_adaptResponseThrow_itReturnAnError() { + func test_response_responseIsValid_adaptResponseThrow_itReturnAnError() async { 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) + do { + _ = try await session.response(for: .test()) + XCTFail() + } + catch { + XCTAssertEqual(error as? CustomError, CustomError()) + } } - func test_publisherFor_rescue_rescueIsSuccess_itRetryRequest() { + func test_response_rescue_rescueIsSuccess_itRetryRequest() async throws { 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() + return Just(()).setFailureType(to: Error.self).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) + _ = try await session.response(for: .void()) - wait(for: [expectation], timeout: 1) + XCTAssertTrue(isRescued) } - func test_publisherFor_outputIsDecoded_itCallInterceptorReceivedResponse() { + func test_response_outputIsDecoded_itCallInterceptorReceivedResponse() async throws { 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) + _ = try await session.response(for: .test()) + } + + func test_response_httpDataHasCustomError_returnCustomError() async throws { + let session = Session( + baseURL: baseURL, + configuration: SessionConfiguration(encoder: encoder, decoder: decoder, dataError: CustomError.self), + dataPublisher: { _ in + Just((data: try! JSONEncoder().encode(CustomError()), response: .unauthorized)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + }) - wait(for: [expectation], timeout: 1) + do { + _ = try await session.response(for: .test()) + XCTFail() + } + catch { + XCTAssertEqual(error as? CustomError, CustomError()) + } } /// helper to create a session for testing @@ -136,7 +109,7 @@ private struct Content: Codable, Equatable { let value: String } -private struct CustomError: Error, Equatable { +private struct CustomError: Error, Codable, Equatable { }