diff --git a/Sources/SimpleHTTP/ContentData/ContentDataCoderConfiguration.swift b/Sources/SimpleHTTP/ContentData/ContentDataCoderConfiguration.swift new file mode 100644 index 0000000..62e3623 --- /dev/null +++ b/Sources/SimpleHTTP/ContentData/ContentDataCoderConfiguration.swift @@ -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) -> 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) -> ContentDataDecoder? { + get { self[HTTPContentType.self[keyPath: keyPath]] } + set { self[HTTPContentType.self[keyPath: keyPath]] = newValue } + } +} diff --git a/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift b/Sources/SimpleHTTP/ContentData/FormURL/FormURLEncoder.swift similarity index 100% rename from Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift rename to Sources/SimpleHTTP/ContentData/FormURL/FormURLEncoder.swift diff --git a/Sources/SimpleHTTP/DataCoder/FormURL/HTTPContentType+FormURL.swift b/Sources/SimpleHTTP/ContentData/FormURL/HTTPContentType+FormURL.swift similarity index 100% rename from Sources/SimpleHTTP/DataCoder/FormURL/HTTPContentType+FormURL.swift rename to Sources/SimpleHTTP/ContentData/FormURL/HTTPContentType+FormURL.swift diff --git a/Sources/SimpleHTTP/Session/Session.swift b/Sources/SimpleHTTP/Session/Session.swift index 18a74d7..2f5b635 100644 --- a/Sources/SimpleHTTP/Session/Session.swift +++ b/Sources/SimpleHTTP/Session/Session.swift @@ -44,8 +44,12 @@ public class Session { public func response(for request: Request) 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) @@ -67,15 +71,31 @@ public class Session { extension Session { private func dataPublisher(for request: Request) async throws -> Response { 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) @@ -91,9 +111,18 @@ extension Session { private func log(_ response: Result, for request: Request) { 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 { let data: Data + let contentType: HTTPContentType let request: Request } diff --git a/Sources/SimpleHTTP/Session/SessionConfiguration.swift b/Sources/SimpleHTTP/Session/SessionConfiguration.swift index f26e184..4396f4d 100644 --- a/Sources/SimpleHTTP/Session/SessionConfiguration.swift +++ b/Sources/SimpleHTTP/Session/SessionConfiguration.swift @@ -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( - 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) +} diff --git a/Sources/SimpleHTTPFoundation/Foundation/Coder/DataCoder.swift b/Sources/SimpleHTTPFoundation/Foundation/Coder/DataCoder.swift index 5e486a4..83067c4 100644 --- a/Sources/SimpleHTTPFoundation/Foundation/Coder/DataCoder.swift +++ b/Sources/SimpleHTTPFoundation/Foundation/Coder/DataCoder.swift @@ -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 diff --git a/Sources/SimpleHTTPFoundation/HTTP/HTTPHeader.swift b/Sources/SimpleHTTPFoundation/HTTP/HTTPHeader.swift index d931ad8..f1c3f1f 100644 --- a/Sources/SimpleHTTPFoundation/HTTP/HTTPHeader.swift +++ b/Sources/SimpleHTTPFoundation/HTTP/HTTPHeader.swift @@ -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 + } +} diff --git a/Tests/SimpleHTTPTests/DataCoder/FormURL/FormURLEncoderTests.swift b/Tests/SimpleHTTPTests/ContentData/FormURL/FormURLEncoderTests.swift similarity index 100% rename from Tests/SimpleHTTPTests/DataCoder/FormURL/FormURLEncoderTests.swift rename to Tests/SimpleHTTPTests/ContentData/FormURL/FormURLEncoderTests.swift diff --git a/Tests/SimpleHTTPTests/Session/SessionTests.swift b/Tests/SimpleHTTPTests/Session/SessionTests.swift index fee414f..63c380d 100644 --- a/Tests/SimpleHTTPTests/Session/SessionTests.swift +++ b/Tests/SimpleHTTPTests/Session/SessionTests.swift @@ -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") @@ -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 @@ -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) }) @@ -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() }) } }