Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions Sources/SimpleHTTP/ContentData/ContentDataCoderConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation

public struct ContentDataCoderConfiguration {
public var encoder: ContentDataEncoderConfiguration
public var decoder: ContentDataDecoderConfiguration
public let defaultType: HTTPContentType

public init(
default: HTTPContentType,
encoder: ContentDataEncoderConfiguration,
decoder: ContentDataDecoderConfiguration,
) {
self.encoder = encoder
self.decoder = decoder
self.defaultType = `default`
}

public init() {
self.init(
default: .json,
encoder: [
.json: JSONEncoder(),
.formURLEncoded: FormURLEncoder()
],
decoder: [
.json: JSONDecoder()
]
)
}
}

@dynamicMemberLookup
public struct ContentDataEncoderConfiguration: ExpressibleByDictionaryLiteral {
private var encoders: [HTTPContentType: ContentDataEncoder]

public init(encoders: [HTTPContentType: ContentDataEncoder]) {
self.encoders = encoders
}

public init(dictionaryLiteral elements: (HTTPContentType, ContentDataEncoder)...) {
self.init(encoders: Dictionary(uniqueKeysWithValues: elements))
}

public subscript(contentType: HTTPContentType) -> ContentDataEncoder? {
get { encoders[contentType] }
set { encoders[contentType] = newValue }
}

public subscript(dynamicMember keyPath: KeyPath<HTTPContentType.Type, HTTPContentType>) -> ContentDataEncoder? {
get { self[HTTPContentType.self[keyPath: keyPath]] }
set { self[HTTPContentType.self[keyPath: keyPath]] = newValue }
}
}

@dynamicMemberLookup
public struct ContentDataDecoderConfiguration: ExpressibleByDictionaryLiteral {
private var decoders: [HTTPContentType: ContentDataDecoder]

public init(decoders: [HTTPContentType: ContentDataDecoder]) {
self.decoders = decoders
}

public init(dictionaryLiteral elements: (HTTPContentType, ContentDataDecoder)...) {
self.init(decoders: Dictionary(uniqueKeysWithValues: elements))
}

public subscript(contentType: HTTPContentType) -> ContentDataDecoder? {
get { decoders[contentType] }
set { decoders[contentType] = newValue }
}

public subscript(dynamicMember keyPath: KeyPath<HTTPContentType.Type, HTTPContentType>) -> ContentDataDecoder? {
get { self[HTTPContentType.self[keyPath: keyPath]] }
set { self[HTTPContentType.self[keyPath: keyPath]] = newValue }
}
}
39 changes: 34 additions & 5 deletions Sources/SimpleHTTP/Session/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,12 @@ public class Session {
public func response<Output: Decodable>(for request: Request<Output>) async throws -> Output {
let result = try await dataPublisher(for: request)

guard let decoder = config.data.decoder[result.contentType] else {
throw SessionConfigurationError.missingDecoder(result.contentType)
}

do {
let decodedOutput = try config.decoder.decode(Output.self, from: result.data)
let decodedOutput = try decoder.decode(Output.self, from: result.data)
let output = try config.interceptor.adaptOutput(decodedOutput, for: result.request)

log(.success(output), for: result.request)
Expand All @@ -67,15 +71,31 @@ public class Session {
extension Session {
private func dataPublisher<Output>(for request: Request<Output>) async throws -> Response<Output> {
let modifiedRequest = try await config.interceptor.adaptRequest(request)
let urlRequest = try modifiedRequest
.toURLRequest(encoder: config.encoder, relativeTo: baseURL, accepting: config.decoder)
let requestContentType = modifiedRequest.headers.contentType ?? config.data.defaultType
let encoder: ContentDataEncoder?

// FIXME: we also check body inside toURLRequest
switch modifiedRequest.body {
case .encodable:
encoder = config.data.encoder[requestContentType]
case .multipart, .none:
// this one is supposed to never be nil
encoder = config.data.encoder[config.data.defaultType]
}

guard let encoder else {
throw SessionConfigurationError.missingEncoder(requestContentType)
}

let urlRequest = try modifiedRequest.toURLRequest(encoder: encoder, relativeTo: baseURL)

do {
let result = try await dataTask(urlRequest)
let responseContentType = result.response.mimeType.map(HTTPContentType.init(value:)) ?? config.data.defaultType

try result.validate(errorDecoder: config.errorConverter)
try result.validate(errorDecoder: errorDecoder(for: responseContentType))

return Response(data: result.data, request: modifiedRequest)
return Response(data: result.data, contentType: responseContentType, request: modifiedRequest)
}
catch {
self.log(.failure(error), for: modifiedRequest)
Expand All @@ -91,9 +111,18 @@ extension Session {
private func log<Output>(_ response: Result<Output, Error>, for request: Request<Output>) {
config.interceptor.receivedResponse(response, for: request)
}

private func errorDecoder(for contentType: HTTPContentType) throws -> DataErrorDecoder? {
guard let converter = config.errorConverter else {
return nil
}

return { data in try converter(data, contentType) }
}
}

private struct Response<Output> {
let data: Data
let contentType: HTTPContentType
let request: Request<Output>
}
36 changes: 19 additions & 17 deletions Sources/SimpleHTTP/Session/SessionConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,46 @@ 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
/// data encoders/decoders configuration per content type
let data: ContentDataCoderConfiguration
/// 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: DataErrorDecoder?
/// a function decoding data as a custom error given the response content type
private(set) var errorConverter: ContentDataErrorDecoder?

/// - Parameter encoder to use for request bodies
/// - Parameter decoder used to decode http responses
/// - Parameter data: encoders/decoders configuration per content type
/// - 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(),
data: ContentDataCoderConfiguration = .init(),
decodingQueue: DispatchQueue = .main,
interceptors: CompositeInterceptor = []) {
self.encoder = encoder
self.decoder = decoder
self.data = data
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(),
data: ContentDataCoderConfiguration,
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)
self.init(data: data, decodingQueue: decodingQueue, interceptors: interceptors)
self.errorConverter = { [decoder=data.decoder] data, contentType in
guard let decoder = decoder[contentType] else {
throw SessionConfigurationError.missingDecoder(contentType)
}
return try decoder.decode(dataError, from: data)
}
}
}

public enum SessionConfigurationError: Error {
case missingEncoder(HTTPContentType)
case missingDecoder(HTTPContentType)
}
2 changes: 2 additions & 0 deletions Sources/SimpleHTTPFoundation/Foundation/Coder/DataCoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@ public protocol ContentDataDecoder: DataDecoder {

/// A function converting data when a http error occur into a custom error
public typealias DataErrorDecoder = (Data) throws -> Error

public typealias ContentDataErrorDecoder = (Data, HTTPContentType) throws -> Error
15 changes: 15 additions & 0 deletions Sources/SimpleHTTPFoundation/HTTP/HTTPHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,18 @@ extension HTTPHeader {
public static let proxyAuthorization: Self = "Proxy-Authorization"
public static let wwwAuthenticate: Self = "WWW-Authenticate"
}

public extension HTTPHeaderFields {
/// - Returns the content type if HTTPHeader.contentType was set
var contentType: HTTPContentType? {
self[.contentType].map(HTTPContentType.init(value:))
}

func contentType(_ value: HTTPContentType) -> Self {
var copy = self

copy[.contentType] = value.value

return copy
}
}
17 changes: 10 additions & 7 deletions Tests/SimpleHTTPTests/Session/SessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import XCTest

class SessionAsyncTests: XCTestCase {
let baseURL = URL(string: "https://sessionTests.io")!
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = ContentDataCoderConfiguration(
default: .json,
encoder: [.json: JSONEncoder()],
decoder: [.json: JSONDecoder()]
)

func test_response_responseIsValid_decodedOutputIsReturned() async throws {
let expectedResponse = Content(value: "response")
Expand All @@ -21,7 +24,7 @@ class SessionAsyncTests: XCTestCase {
let interceptor = InterceptorStub()
let session = sesssionStub(
interceptor: [interceptor],
data: { URLDataResponse(data: try! JSONEncoder().encode(output), response: .success) }
response: { URLDataResponse(data: try! JSONEncoder().encode(output), response: .success) }
)

interceptor.adaptResponseMock = { _, _ in
Expand Down Expand Up @@ -73,7 +76,7 @@ class SessionAsyncTests: XCTestCase {
func test_response_httpDataHasCustomError_returnCustomError() async throws {
let session = Session(
baseURL: baseURL,
configuration: SessionConfiguration(encoder: encoder, decoder: decoder, dataError: CustomError.self),
configuration: SessionConfiguration(data: data, dataError: CustomError.self),
dataTask: { _ in
URLDataResponse(data: try! JSONEncoder().encode(CustomError()), response: .unauthorized)
})
Expand All @@ -88,11 +91,11 @@ class SessionAsyncTests: XCTestCase {
}

/// helper to create a session for testing
private func sesssionStub(interceptor: CompositeInterceptor = [], data: @escaping () throws -> URLDataResponse)
private func sesssionStub(interceptor: CompositeInterceptor = [], response: @escaping () throws -> URLDataResponse)
-> Session {
let config = SessionConfiguration(encoder: encoder, decoder: decoder, interceptors: interceptor)
let config = SessionConfiguration(data: data, interceptors: interceptor)

return Session(baseURL: baseURL, configuration: config, dataTask: { _ in try data() })
return Session(baseURL: baseURL, configuration: config, dataTask: { _ in try response() })
}
}

Expand Down
Loading