diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c59ce1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +trim_trailing_whitespace = true + +# 4 space indentation +[*.swift] +indent_style = space +indent_size = 4 \ No newline at end of file diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 925b17f..5394a25 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -7,11 +7,11 @@ on: jobs: doc: - runs-on: macos-latest + runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Publish Jazzy Docs uses: steven0351/publish-jazzy-docs@v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7090a36..74d09d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,14 +10,16 @@ on: jobs: test: - runs-on: macos-11 + runs-on: macos-26 steps: - name: Checkout repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Lint code - run: swiftlint lint + - name: Lint PR code + uses: stanfordbdhg/action-swiftlint@v4 + env: + DIFF_BASE: ${{ github.base_ref }} - name: Run tests - run: swift test --enable-test-discovery + run: swift test diff --git a/Package.swift b/Package.swift index 8b1d861..51fc057 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/README.md b/README.md index 312ecd3..64fbcb8 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,7 @@ You can also use a `Session` object. [`Session`](https://pjechris.github.io/Simp ```swift -let session = Session( - baseURL: URL(string: "https://github.com")!, - encoder: JSONEncoder(), - decoder: JSONDecoder() -) +let session = Session(baseURL: URL(string: "https://github.com")!) try await session.response(for: .login(UserBody(username: "pjechris", password: "MyPassword"))) @@ -65,9 +61,24 @@ try await session.response(for: .login(UserBody(username: "pjechris", password: A few words about Session: - `baseURL` will be prepended to all call paths -- You can skip encoder and decoder if you use JSON +- By default JSON is used for both encoding and decoding, so you don't have to configure anything for JSON APIs - You can provide a custom `URLSession` instance if ever needed +### Customizing encoders/decoders + +Encoders and decoders are configured per content type through a `SessionConfiguration`. Pass a `ContentDataCodersConfiguration` to register the coders you need: + +```swift +let session = Session( + baseURL: URL(string: "https://github.com")!, + configuration: SessionConfiguration( + data: ContentDataCodersConfiguration() + .encoding(.json, with: JSONEncoder()) + .decoding(.json, with: JSONDecoder()) + ) +) +``` + ## Send a body Request support two body types: diff --git a/Sources/SimpleHTTP/ContentData/ContentDataCodersConfiguration.swift b/Sources/SimpleHTTP/ContentData/ContentDataCodersConfiguration.swift new file mode 100644 index 0000000..b077577 --- /dev/null +++ b/Sources/SimpleHTTP/ContentData/ContentDataCodersConfiguration.swift @@ -0,0 +1,53 @@ +import Foundation + +public typealias ContentDataEncodersConfiguration = [HTTPContentType: ContentDataEncoder] +public typealias ContentDataDecodersConfiguration = [HTTPContentType: ContentDataDecoder] + +/// Defines the list of encoders and decoders to use. +public struct ContentDataCodersConfiguration { + public var encoders: ContentDataEncodersConfiguration + public var decoders: ContentDataDecodersConfiguration + public let defaultType: HTTPContentType + + public init( + default: HTTPContentType, + encoders: ContentDataEncodersConfiguration, + decoders: ContentDataDecodersConfiguration, + ) { + self.encoders = encoders + self.decoders = decoders + self.defaultType = `default` + } + + /// Creates a configuration with default coders and decoders. + /// + /// - Note: default encoder/decoder is set to JSON + public init() { + self.init( + default: .json, + encoders: [ + .json: JSONEncoder(), + .formURLEncoded: FormURLEncoder() + ], + decoders: [ + .json: JSONDecoder() + ] + ) + } +} + +extension ContentDataCodersConfiguration { + /// defines a single encoder to use for encoding `contentType` requests. If an encoder was already defined it will be replaced with the new value + public func encoding(_ contentType: HTTPContentType, with encoder: ContentDataEncoder) -> Self { + var copy = self + copy.encoders[contentType] = encoder + return copy + } + + /// defines a single decoder for decoding `contentType` responses. If a decoder was already defined it will be replaced with the new value + public func decoding(_ contentType: HTTPContentType, with decoder: ContentDataDecoder) -> Self { + var copy = self + copy.decoders[contentType] = decoder + return copy + } +} diff --git a/Sources/SimpleHTTP/ContentData/FormURL/FormURLEncoder.swift b/Sources/SimpleHTTP/ContentData/FormURL/FormURLEncoder.swift new file mode 100644 index 0000000..3799809 --- /dev/null +++ b/Sources/SimpleHTTP/ContentData/FormURL/FormURLEncoder.swift @@ -0,0 +1,90 @@ +import Foundation + +public struct FormURLEncoder: ContentDataEncoder { + public static let contentType: HTTPContentType = .formURLEncoded + + public init() { } + + public func encode(_ value: some Encodable) throws -> Data { + let encoder = FormKeyValueEncoder() + try value.encode(to: encoder) + + let encoded = encoder.pairs + .map { key, value in + let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key + let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value + return "\(encodedKey)=\(encodedValue)" + } + .joined(separator: "&") + + guard let data = encoded.data(using: .utf8) else { + throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "UTF-8 encoding failed")) + } + + return data + } +} + +// MARK: - Encoder + +private final class FormKeyValueEncoder: Encoder { + var codingPath: [CodingKey] = [] + var userInfo: [CodingUserInfoKey: Any] = [:] + var pairs: [(key: String, value: String)] = [] + + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { + KeyedEncodingContainer(FormKeyedContainer(encoder: self)) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + fatalError("Form URL encoding does not support unkeyed containers") + } + + func singleValueContainer() -> SingleValueEncodingContainer { + fatalError("Form URL encoding does not support single value containers") + } +} + +// MARK: - Keyed container + +private struct FormKeyedContainer: KeyedEncodingContainerProtocol { + let encoder: FormKeyValueEncoder + var codingPath: [CodingKey] = [] + + mutating func encodeNil(forKey key: Key) throws {} + + mutating func encode(_ value: String, forKey key: Key) throws { + append(key, value) + } + + mutating func encode(_ value: Bool, forKey key: Key) throws { + append(key, "\(value)") + } + + mutating func encode(_ value: Int, forKey key: Key) throws { + append(key, "\(value)") + } + + mutating func encode(_ value: Double, forKey key: Key) throws { + append(key, "\(value)") + } + + mutating func encode(_ value: T, forKey key: Key) throws { + append(key, "\(value)") + } + + mutating func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { + fatalError("Nested containers not supported") + } + + mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + fatalError("Nested containers not supported") + } + + mutating func superEncoder() -> Encoder { encoder } + mutating func superEncoder(forKey key: Key) -> Encoder { encoder } + + private func append(_ key: Key, _ value: String) { + encoder.pairs.append((key: key.stringValue, value: value)) + } +} diff --git a/Sources/SimpleHTTP/ContentData/FormURL/HTTPContentType+FormURL.swift b/Sources/SimpleHTTP/ContentData/FormURL/HTTPContentType+FormURL.swift new file mode 100644 index 0000000..5d94cb4 --- /dev/null +++ b/Sources/SimpleHTTP/ContentData/FormURL/HTTPContentType+FormURL.swift @@ -0,0 +1,5 @@ +import SimpleHTTPFoundation + +extension HTTPContentType { + public static let formURLEncoded: HTTPContentType = "application/x-www-form-urlencoded" +} diff --git a/Sources/SimpleHTTP/Interceptor/CompositeInterceptor.swift b/Sources/SimpleHTTP/Interceptor/CompositeInterceptor.swift index 8c489fd..3880b74 100644 --- a/Sources/SimpleHTTP/Interceptor/CompositeInterceptor.swift +++ b/Sources/SimpleHTTP/Interceptor/CompositeInterceptor.swift @@ -1,5 +1,4 @@ import Foundation -import Combine /// Use an Array of `Interceptor` as a single `Interceptor` public struct CompositeInterceptor: ExpressibleByArrayLiteral, Sequence { @@ -15,10 +14,10 @@ public struct CompositeInterceptor: ExpressibleByArrayLiteral, Sequence { } extension CompositeInterceptor: Interceptor { - public func adaptRequest(_ request: Request) async -> Request { + public func adaptRequest(_ request: Request) async throws -> Request { var request = request for interceptor in interceptors { - request = await interceptor.adaptRequest(request) + request = try await interceptor.adaptRequest(request) } return request diff --git a/Sources/SimpleHTTP/Interceptor/Interceptor.swift b/Sources/SimpleHTTP/Interceptor/Interceptor.swift index e45df34..f64ea37 100644 --- a/Sources/SimpleHTTP/Interceptor/Interceptor.swift +++ b/Sources/SimpleHTTP/Interceptor/Interceptor.swift @@ -1,12 +1,11 @@ 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) async -> Request + func adaptRequest(_ request: Request) async throws -> Request /// catch and retry a failed request /// - Returns: nil if the request should not be retried. Otherwise a publisher that will be executed before diff --git a/Sources/SimpleHTTP/MultipartForm/MultipartFormData.swift b/Sources/SimpleHTTP/MultipartForm/MultipartFormData.swift index 3fe7e6b..38d732c 100644 --- a/Sources/SimpleHTTP/MultipartForm/MultipartFormData.swift +++ b/Sources/SimpleHTTP/MultipartForm/MultipartFormData.swift @@ -7,7 +7,7 @@ import MobileCoreServices import CoreServices #endif -struct Header: Hashable { +struct Header: Hashable, Sendable { let name: HTTPHeader let value: String } @@ -16,9 +16,9 @@ enum EncodingCharacters { static let crlf = "\r\n" } -enum Boundary { +enum Boundary: Sendable { - enum `Type` { + enum `Type`: Sendable { case initial case encapsulated case final @@ -49,19 +49,19 @@ enum Boundary { struct BodyPart { let headers: [Header] let length: Int - let stream: () throws -> InputStream + let stream: @Sendable () throws -> InputStream var hasInitialBoundary = false var hasFinalBoundary = false - private init(headers: [Header], stream: @escaping () throws -> InputStream, length: Int) { + private init(headers: [Header], stream: @Sendable @escaping () throws -> InputStream, length: Int) { self.headers = headers self.length = length self.stream = stream } init(headers: [Header], url: URL, length: Int) { - let stream: () throws -> InputStream = { + let stream: @Sendable () throws -> InputStream = { guard let stream = InputStream(url: url) else { throw MultipartFormData.Error.inputStreamCreationFailed(url) } diff --git a/Sources/SimpleHTTP/Request/Path.swift b/Sources/SimpleHTTP/Request/Path.swift index 3cfc1fd..52ce478 100644 --- a/Sources/SimpleHTTP/Request/Path.swift +++ b/Sources/SimpleHTTP/Request/Path.swift @@ -21,7 +21,7 @@ import Foundation /// /// let user: Path = .myPaths.user /// ``` -public struct Path: Equatable, ExpressibleByStringLiteral, ExpressibleByStringInterpolation { +public struct Path: Equatable, Sendable, ExpressibleByStringLiteral, ExpressibleByStringInterpolation { /// relative path public let value: String diff --git a/Sources/SimpleHTTP/Request/Request.swift b/Sources/SimpleHTTP/Request/Request.swift index 269954c..b579ad0 100644 --- a/Sources/SimpleHTTP/Request/Request.swift +++ b/Sources/SimpleHTTP/Request/Request.swift @@ -1,6 +1,6 @@ import Foundation -public enum Method: String { +public enum Method: String, Sendable { case get case post case put diff --git a/Sources/SimpleHTTP/Response/DataResponse.swift b/Sources/SimpleHTTP/Response/DataResponse.swift index def8101..b7b27b7 100644 --- a/Sources/SimpleHTTP/Response/DataResponse.swift +++ b/Sources/SimpleHTTP/Response/DataResponse.swift @@ -1,8 +1,13 @@ import Foundation -public struct URLDataResponse { +public struct URLDataResponse: Sendable { public let data: Data public let response: HTTPURLResponse + + public init(data: Data, response: HTTPURLResponse) { + self.data = data + self.response = response + } } extension URLDataResponse { diff --git a/Sources/SimpleHTTP/Session/Session+Combine.swift b/Sources/SimpleHTTP/Session/Session+Combine.swift deleted file mode 100644 index 67933ff..0000000 --- a/Sources/SimpleHTTP/Session/Session+Combine.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -#if canImport(Combine) -import Combine - -extension Session { - /// 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 { - let subject = PassthroughSubject() - - Task { - do { - subject.send(try await response(for: request)) - subject.send(completion: .finished) - } - catch { - subject.send(completion: .failure(error)) - } - } - - return subject.eraseToAnyPublisher() - } - - public func publisher(for request: Request) -> AnyPublisher { - let subject = PassthroughSubject() - - Task { - do { - subject.send(try await response(for: request)) - subject.send(completion: .finished) - } - catch { - subject.send(completion: .failure(error)) - } - } - - return subject.eraseToAnyPublisher() - } -} - -#endif diff --git a/Sources/SimpleHTTP/Session/Session.swift b/Sources/SimpleHTTP/Session/Session.swift index ed64540..e6fa124 100644 --- a/Sources/SimpleHTTP/Session/Session.swift +++ b/Sources/SimpleHTTP/Session/Session.swift @@ -41,11 +41,16 @@ public class Session { /// /// The request is validated and decoded appropriately on success. /// - Returns: a async Output on success, an error otherwise + @concurrent public func response(for request: Request) async throws -> Output { let result = try await dataPublisher(for: request) + guard let decoder = config.data.decoders[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) @@ -58,6 +63,7 @@ public class Session { } /// Perform asynchronously `request` which has no return value + @concurrent public func response(for request: Request) async throws { let result = try await dataPublisher(for: request) log(.success(()), for: result.request) @@ -66,16 +72,32 @@ public class Session { extension Session { private func dataPublisher(for request: Request) async throws -> Response { - let modifiedRequest = await config.interceptor.adaptRequest(request) - let urlRequest = try modifiedRequest - .toURLRequest(encoder: config.encoder, relativeTo: baseURL, accepting: config.decoder) + let modifiedRequest = try await config.interceptor.adaptRequest(request) + 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.encoders[requestContentType] + case .multipart, .none: + // this one is supposed to never be nil + encoder = config.data.encoders[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 +113,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..957f3de 100644 --- a/Sources/SimpleHTTP/Session/SessionConfiguration.swift +++ b/Sources/SimpleHTTP/Session/SessionConfiguration.swift @@ -2,44 +2,41 @@ 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 + /// data encoders/decoders configuration per content type + let data: ContentDataCodersConfiguration /// 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(), - decodingQueue: DispatchQueue = .main, + data: ContentDataCodersConfiguration = .init(), interceptors: CompositeInterceptor = []) { - self.encoder = encoder - self.decoder = decoder - self.decodingQueue = decodingQueue + self.data = data 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, + data: ContentDataCodersConfiguration, 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, interceptors: interceptors) + self.errorConverter = { [decoders=data.decoders] data, contentType in + guard let decoder = decoders[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/Foundation/URLRequest/URLRequest+URL.swift b/Sources/SimpleHTTPFoundation/Foundation/URLRequest/URLRequest+URL.swift index 2e69001..854efd8 100644 --- a/Sources/SimpleHTTPFoundation/Foundation/URLRequest/URLRequest+URL.swift +++ b/Sources/SimpleHTTPFoundation/Foundation/URLRequest/URLRequest+URL.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + extension URLRequest { /// Return a new URLRequest whose path is relative to `baseURL` public func relativeTo(_ baseURL: URL) -> URLRequest { diff --git a/Sources/SimpleHTTPFoundation/Foundation/URLSession+Async.swift b/Sources/SimpleHTTPFoundation/Foundation/URLSession+Async.swift index db5a0b7..a64ee86 100644 --- a/Sources/SimpleHTTPFoundation/Foundation/URLSession+Async.swift +++ b/Sources/SimpleHTTPFoundation/Foundation/URLSession+Async.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + extension URLSession { @available(iOS, deprecated: 15.0, message: "Use built-in API instead") public func data(for urlRequest: URLRequest) async throws -> (Data, URLResponse) { diff --git a/Sources/SimpleHTTPFoundation/HTTP/HTTPContentType.swift b/Sources/SimpleHTTPFoundation/HTTP/HTTPContentType.swift index 7eaf3bb..1af6ac7 100644 --- a/Sources/SimpleHTTPFoundation/HTTP/HTTPContentType.swift +++ b/Sources/SimpleHTTPFoundation/HTTP/HTTPContentType.swift @@ -1,7 +1,7 @@ import Foundation /// A struct representing a http header content type value -public struct HTTPContentType: Hashable, ExpressibleByStringLiteral { +public struct HTTPContentType: Hashable, Sendable, ExpressibleByStringLiteral { public let value: String public init(value: String) { diff --git a/Sources/SimpleHTTPFoundation/HTTP/HTTPHeader.swift b/Sources/SimpleHTTPFoundation/HTTP/HTTPHeader.swift index d931ad8..b4b9eb1 100644 --- a/Sources/SimpleHTTPFoundation/HTTP/HTTPHeader.swift +++ b/Sources/SimpleHTTPFoundation/HTTP/HTTPHeader.swift @@ -4,7 +4,7 @@ import Foundation public typealias HTTPHeaderFields = [HTTPHeader: String] /// A struct representing a http request header key -public struct HTTPHeader: Hashable, ExpressibleByStringLiteral { +public struct HTTPHeader: Hashable, Sendable, ExpressibleByStringLiteral { public let key: String public init(stringLiteral value: StringLiteralType) { @@ -17,7 +17,7 @@ extension HTTPHeader { public static let authentication: Self = "Authentication" public static let authorization: Self = "Authorization" public static let contentType: Self = "Content-Type" - public static var contentDisposition: Self = "Content-Disposition" + public static let contentDisposition: Self = "Content-Disposition" } // swiftlint:disable line_length @@ -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/SimpleHTTPFoundationTests/Foundation/URLRequest/URLRequestTests+URL.swift b/Tests/SimpleHTTPFoundationTests/Foundation/URLRequest/URLRequestTests+URL.swift index 3d18b40..c7f0365 100644 --- a/Tests/SimpleHTTPFoundationTests/Foundation/URLRequest/URLRequestTests+URL.swift +++ b/Tests/SimpleHTTPFoundationTests/Foundation/URLRequest/URLRequestTests+URL.swift @@ -2,6 +2,10 @@ import Foundation import XCTest @testable import SimpleHTTPFoundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + class URLRequestURLTests: XCTestCase { func test_relativeTo_requestURLHasBaseURL() { let request = URLRequest(url: URL(string: "path")!) diff --git a/Tests/SimpleHTTPTests/ContentData/FormURL/FormURLEncoderTests.swift b/Tests/SimpleHTTPTests/ContentData/FormURL/FormURLEncoderTests.swift new file mode 100644 index 0000000..92be3c3 --- /dev/null +++ b/Tests/SimpleHTTPTests/ContentData/FormURL/FormURLEncoderTests.swift @@ -0,0 +1,122 @@ +import Testing +import Foundation +import SimpleHTTP + +struct FormURLEncoderTests { + let encoder = FormURLEncoder() + + struct Encode { + let encoder = FormURLEncoder() + + @Test("single string field returns key=value pair") + func singleStringField_returnsKeyValuePair() throws { + let input = SingleField(name: "John") + + let data = try encoder.encode(input) + + #expect(String(data: data, encoding: .utf8) == "name=John") + } + + @Test("multiple fields returns ampersand-separated pairs") + func multipleFields_returnsAmpersandSeparatedPairs() throws { + let input = MultipleFields(name: "John", age: 30) + + let data = try encoder.encode(input) + + #expect(String(data: data, encoding: .utf8) == "name=John&age=30") + } + + @Test("boolean field returns true or false") + func booleanField_returnsTrueOrFalse() throws { + let input = BoolField(active: true) + + let data = try encoder.encode(input) + + #expect(String(data: data, encoding: .utf8) == "active=true") + } + + @Test("double field returns decimal value") + func doubleField_returnsDecimalValue() throws { + let input = DoubleField(score: 9.5) + + let data = try encoder.encode(input) + + #expect(String(data: data, encoding: .utf8) == "score=9.5") + } + + @Test("spaces in value are percent-encoded") + func spacesInValue_arePercentEncoded() throws { + let input = SingleField(name: "John Doe") + + let data = try encoder.encode(input) + let result = String(data: data, encoding: .utf8) + + #expect(result == "name=John%20Doe") + } + + @Test("special characters in key are percent-encoded") + func specialCharactersInKey_arePercentEncoded() throws { + let input = SpecialKeyField(value: "hello") + + let data = try encoder.encode(input) + let result = String(data: data, encoding: .utf8) + + #expect(result?.contains("my%20key=hello") == true) + } + + @Test("nil optional field is omitted") + func nilOptionalField_isOmitted() throws { + let input = OptionalField(name: "John", nickname: nil) + + let data = try encoder.encode(input) + + #expect(String(data: data, encoding: .utf8) == "name=John") + } + + @Test("present optional field is included") + func presentOptionalField_isIncluded() throws { + let input = OptionalField(name: "John", nickname: "JD") + + let data = try encoder.encode(input) + + #expect(String(data: data, encoding: .utf8) == "name=John&nickname=JD") + } + + @Test("content type is form URL encoded") + func contentType_returnsFormURLEncoded() { + #expect(FormURLEncoder.contentType == .formURLEncoded) + } + } +} + +// MARK: - Fixtures + +private struct SingleField: Encodable { + let name: String +} + +private struct MultipleFields: Encodable { + let name: String + let age: Int +} + +private struct BoolField: Encodable { + let active: Bool +} + +private struct DoubleField: Encodable { + let score: Double +} + +private struct OptionalField: Encodable { + let name: String + let nickname: String? +} + +private struct SpecialKeyField: Encodable { + enum CodingKeys: String, CodingKey { + case value = "my key" + } + + let value: String +} diff --git a/Tests/SimpleHTTPTests/Response/URLDataResponseTests.swift b/Tests/SimpleHTTPTests/Response/URLDataResponseTests.swift index 635a56c..7094fc4 100644 --- a/Tests/SimpleHTTPTests/Response/URLDataResponseTests.swift +++ b/Tests/SimpleHTTPTests/Response/URLDataResponseTests.swift @@ -1,19 +1,13 @@ import XCTest -import Combine @testable import SimpleHTTP class URLDataResponseTests: XCTestCase { - var cancellables: Set = [] - - override func tearDown() { - cancellables = [] - } func test_validate_responseIsError_dataIsEmpty_converterIsNotCalled() throws { let response = URLDataResponse(data: Data(), response: HTTPURLResponse.notFound) let transformer: DataErrorDecoder = { _ in XCTFail("transformer should not be called when data is empty") - throw NSError() + throw NSError(domain: "test", code: 0) } XCTAssertThrowsError(try response.validate(errorDecoder: transformer)) diff --git a/Tests/SimpleHTTPTests/Session/Session+CombineTests.swift b/Tests/SimpleHTTPTests/Session/Session+CombineTests.swift deleted file mode 100644 index d817ef1..0000000 --- a/Tests/SimpleHTTPTests/Session/Session+CombineTests.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -import XCTest -@testable import SimpleHTTP - -#if canImport(Combine) - -import Combine - -class SessionCombineTests: XCTestCase { - var cancellables: Set = [] - - override func tearDown() { - cancellables.removeAll() - } - - func test_publisher_returnOutput() { - let output = CombineTest(value: "hello world") - let expectation = XCTestExpectation() - - let session = Session(baseURL: URL(string: "/")!) { _ in - URLDataResponse(data: try! JSONEncoder().encode(output), response: .success) - } - - session.publisher(for: Request.get("test")) - .sink( - receiveCompletion: { _ in }, - receiveValue: { - expectation.fulfill() - XCTAssertEqual($0, output) - } - ) - .store(in: &cancellables) - - wait(for: [expectation], timeout: 1) - } -} - -#endif - -private struct CombineTest: Codable, Equatable { - let value: String -} diff --git a/Tests/SimpleHTTPTests/Session/SessionTests.swift b/Tests/SimpleHTTPTests/Session/SessionTests.swift index c147cb4..b32f646 100644 --- a/Tests/SimpleHTTPTests/Session/SessionTests.swift +++ b/Tests/SimpleHTTPTests/Session/SessionTests.swift @@ -1,11 +1,13 @@ import XCTest -import Combine @testable import SimpleHTTP class SessionAsyncTests: XCTestCase { let baseURL = URL(string: "https://sessionTests.io")! - let encoder = JSONEncoder() - let decoder = JSONDecoder() + let data = ContentDataCodersConfiguration( + default: .json, + encoders: [.json: JSONEncoder()], + decoders: [.json: JSONDecoder()] + ) func test_response_responseIsValid_decodedOutputIsReturned() async throws { let expectedResponse = Content(value: "response") @@ -22,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 @@ -74,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) }) @@ -89,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() }) } }