Skip to content

Commit

Permalink
feat(session): Async/await support (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
pjechris authored Apr 24, 2022
1 parent ff4250d commit 6ec589c
Show file tree
Hide file tree
Showing 12 changed files with 150 additions and 98 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/SimpleHTTP/Foundation/Publisher+Validate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ extension Publisher where Output == URLSession.DataTaskPublisher.Output {
public func validate(_ converter: DataErrorConverter? = nil) -> AnyPublisher<Output, Error> {
tryMap { output in
do {
try (output.response as? HTTPURLResponse)?.validate()
try output.response.validate()
return output
}
catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
13 changes: 11 additions & 2 deletions Sources/SimpleHTTP/Foundation/URLResponse+Validate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
30 changes: 15 additions & 15 deletions Sources/SimpleHTTP/Interceptor/Interceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<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>?
/// 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>)
/// 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>)
}
19 changes: 18 additions & 1 deletion Sources/SimpleHTTP/Request/Request+URLRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -17,5 +32,7 @@ extension Request {

return urlRequest
}


}

39 changes: 39 additions & 0 deletions Sources/SimpleHTTP/Session/Session+Async.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation
import Combine

#if canImport(_Concurrency)

extension Session {
public func response<Output: Decodable>(for request: Request<Output>) async throws -> Output {
try await response(publisher: publisher(for: request))
}

public func response(for request: Request<Void>) async throws {
try await response(publisher: publisher(for: request))
}

private func response<Output>(publisher: AnyPublisher<Output, Error>) async throws -> Output {
var cancellable: Set<AnyCancellable> = []
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
4 changes: 1 addition & 3 deletions Sources/SimpleHTTP/Session/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 15 additions & 6 deletions Tests/SimpleHTTPTests/Request/RequestTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,40 @@ class RequestTests: XCTestCase {
case test
}

let baseURL = URL(string: "https://google.fr")!

func test_init_withPathAsString() {
XCTAssertEqual(Request<Void>.get("hello_world").path, "hello_world")
}

func test_toURLRequest_itSetHttpMethod() throws {
func test_toURLRequest_setHttpMethod() throws {
let request = try Request<Void>.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<Void>.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<Void>.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<Void>.get(TestEndpoint.test)
.toURLRequest(encoder: JSONEncoder(), relativeTo: baseURL)

XCTAssertEqual(request.url?.absoluteString, baseURL.absoluteString + "/test")
}

}

private struct Body: Encodable {
Expand Down
Loading

0 comments on commit 6ec589c

Please sign in to comment.