diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift index 6dc2a73..d7092b1 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift @@ -16,6 +16,9 @@ import Foundation /// A container for a parsed, valid MIME type. @_spi(Generated) public struct OpenAPIMIMEType: Equatable { + /// XML MIME type + public static let xml: OpenAPIMIMEType = .init(kind: .concrete(type: "application", subtype: "xml")) + /// The kind of the MIME type. public enum Kind: Equatable { diff --git a/Sources/OpenAPIRuntime/Conversion/Configuration.swift b/Sources/OpenAPIRuntime/Conversion/Configuration.swift index e0b593a..f5ca02b 100644 --- a/Sources/OpenAPIRuntime/Conversion/Configuration.swift +++ b/Sources/OpenAPIRuntime/Conversion/Configuration.swift @@ -96,6 +96,27 @@ extension JSONDecoder.DateDecodingStrategy { } } +/// A type that allows custom content type encoding and decoding. +public protocol CustomCoder: Sendable { + + /// Encodes the given value and returns its custom encoded representation. + /// + /// - Parameter value: The value to encode. + /// - Returns: A new `Data` value containing the custom encoded data. + /// - Throws: An error if encoding fails. + func customEncode(_ value: T) throws -> Data + + /// Decodes a value of the given type from the given custom representation. + /// + /// - Parameters: + /// - type: The type of the value to decode. + /// - data: The data to decode from. + /// - Returns: A value of the requested type. + /// - Throws: An error if decoding fails. + func customDecode(_ type: T.Type, from data: Data) throws -> T + +} + /// A set of configuration values used by the generated client and server types. public struct Configuration: Sendable { @@ -105,17 +126,23 @@ public struct Configuration: Sendable { /// The generator to use when creating mutlipart bodies. public var multipartBoundaryGenerator: any MultipartBoundaryGenerator + /// Custom XML coder for encoding and decoding xml bodies. + public var xmlCoder: (any CustomCoder)? + /// Creates a new configuration with the specified values. /// /// - Parameters: /// - dateTranscoder: The transcoder to use when converting between date /// and string values. /// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies. + /// - xmlCoder: Custom XML coder for encoding and decoding xml bodies. Only required when using XML body payloads. public init( dateTranscoder: any DateTranscoder = .iso8601, - multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random + multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random, + xmlCoder: (any CustomCoder)? = nil ) { self.dateTranscoder = dateTranscoder self.multipartBoundaryGenerator = multipartBoundaryGenerator + self.xmlCoder = xmlCoder } } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift index ea57500..28abbdb 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift @@ -127,6 +127,50 @@ extension Converter { convert: convertBodyCodableToJSON ) } + /// Sets an optional request body as XML in the specified header fields and returns an `HTTPBody`. + /// + /// - Parameters: + /// - value: The optional value to be set as the request body. + /// - headerFields: The header fields in which to set the content type. + /// - contentType: The content type to be set in the header fields. + /// + /// - Returns: An `HTTPBody` representing the XML-encoded request body, or `nil` if the `value` is `nil`. + /// + /// - Throws: An error if setting the request body as XML fails. + public func setOptionalRequestBodyAsXML( + _ value: T?, + headerFields: inout HTTPFields, + contentType: String + ) throws -> HTTPBody? { + try setOptionalRequestBody( + value, + headerFields: &headerFields, + contentType: contentType, + convert: convertBodyCodableToXML + ) + } + /// Sets a required request body as XML in the specified header fields and returns an `HTTPBody`. + /// + /// - Parameters: + /// - value: The value to be set as the request body. + /// - headerFields: The header fields in which to set the content type. + /// - contentType: The content type to be set in the header fields. + /// + /// - Returns: An `HTTPBody` representing the XML-encoded request body. + /// + /// - Throws: An error if setting the request body as XML fails. + public func setRequiredRequestBodyAsXML( + _ value: T, + headerFields: inout HTTPFields, + contentType: String + ) throws -> HTTPBody { + try setRequiredRequestBody( + value, + headerFields: &headerFields, + contentType: contentType, + convert: convertBodyCodableToXML + ) + } /// Sets an optional request body as binary in the specified header fields and returns an `HTTPBody`. /// @@ -275,6 +319,29 @@ extension Converter { convert: convertJSONToBodyCodable ) } + /// Retrieves the response body as XML and transforms it into a specified type. + /// + /// - Parameters: + /// - type: The type to decode the XML into. + /// - data: The HTTP body data containing the XML. + /// - transform: A transformation function to apply to the decoded XML. + /// + /// - Returns: The transformed result of type `C`. + /// + /// - Throws: An error if retrieving or transforming the response body fails. + public func getResponseBodyAsXML( + _ type: T.Type, + from data: HTTPBody?, + transforming transform: (T) -> C + ) async throws -> C { + guard let data else { throw RuntimeError.missingRequiredResponseBody } + return try await getBufferingResponseBody( + type, + from: data, + transforming: transform, + convert: convertXMLToBodyCodable + ) + } /// Retrieves the response body as binary data and transforms it into a specified type. /// diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index e8f3630..75b0f52 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -214,6 +214,47 @@ extension Converter { ) } + /// Retrieves and decodes an optional XML-encoded request body and transforms it to a different type. + /// + /// - Parameters: + /// - type: The type to decode the request body into. + /// - data: The HTTP request body to decode, or `nil` if the body is not present. + /// - transform: A closure that transforms the decoded value to a different type. + /// - Returns: The transformed value, or `nil` if the request body is not present or if decoding fails. + /// - Throws: An error if there are issues decoding or transforming the request body. + public func getOptionalRequestBodyAsXML( + _ type: T.Type, + from data: HTTPBody?, + transforming transform: (T) -> C + ) async throws -> C? { + try await getOptionalBufferingRequestBody( + type, + from: data, + transforming: transform, + convert: convertXMLToBodyCodable + ) + } + /// Retrieves and decodes a required XML-encoded request body and transforms it to a different type. + /// + /// - Parameters: + /// - type: The type to decode the request body into. + /// - data: The HTTP request body to decode, or `nil` if the body is not present. + /// - transform: A closure that transforms the decoded value to a different type. + /// - Returns: The transformed value. + /// - Throws: An error if the request body is not present, if decoding fails, or if there are issues transforming the request body. + public func getRequiredRequestBodyAsXML( + _ type: T.Type, + from data: HTTPBody?, + transforming transform: (T) -> C + ) async throws -> C { + try await getRequiredBufferingRequestBody( + type, + from: data, + transforming: transform, + convert: convertXMLToBodyCodable + ) + } + /// Retrieves and transforms an optional binary request body. /// /// - Parameters: @@ -347,6 +388,24 @@ extension Converter { convert: convertBodyCodableToJSON ) } + /// Sets the response body as XML data, serializing the provided value. + /// + /// - Parameters: + /// - value: The value to be serialized into the response body. + /// - headerFields: The HTTP header fields to update with the new `contentType`. + /// - contentType: The content type to set in the HTTP header fields. + /// - Returns: An `HTTPBody` with the response body set as XML data. + /// - Throws: An error if serialization or setting the response body fails. + public func setResponseBodyAsXML(_ value: T, headerFields: inout HTTPFields, contentType: String) + throws -> HTTPBody + { + try setResponseBody( + value, + headerFields: &headerFields, + contentType: contentType, + convert: convertBodyCodableToXML + ) + } /// Sets the response body as binary data. /// diff --git a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift index 38a1711..ea07d21 100644 --- a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift @@ -144,6 +144,32 @@ extension Converter { return HTTPBody(data) } + /// Returns a value decoded from a XML body. + /// - Parameter body: The body containing the raw XML bytes. + /// - Returns: A decoded value. + /// - Throws: An error if decoding from the body fails. + /// - Throws: An error if no custom coder is present for XML coding. + func convertXMLToBodyCodable(_ body: HTTPBody) async throws -> T { + guard let coder = configuration.xmlCoder else { + throw RuntimeError.missingCoderForCustomContentType(contentType: OpenAPIMIMEType.xml.description) + } + let data = try await Data(collecting: body, upTo: .max) + return try coder.customDecode(T.self, from: data) + } + + /// Returns a XML body for the provided encodable value. + /// - Parameter value: The value to encode as XML. + /// - Returns: The raw XML body. + /// - Throws: An error if encoding to XML fails. + /// - Throws: An error if no custom coder is present for XML coding. + func convertBodyCodableToXML(_ value: T) throws -> HTTPBody { + guard let coder = configuration.xmlCoder else { + throw RuntimeError.missingCoderForCustomContentType(contentType: OpenAPIMIMEType.xml.description) + } + let data = try coder.customEncode(value) + return HTTPBody(data) + } + /// Returns a value decoded from a URL-encoded form body. /// - Parameter body: The body containing the raw URL-encoded form bytes. /// - Returns: A decoded value. diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index bf030d1..1cfa5c9 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -22,3 +22,19 @@ extension UndocumentedPayload { self.init(headerFields: [:], body: nil) } } + +extension Configuration { + /// Creates a new configuration with the specified values. + /// + /// - Parameters: + /// - dateTranscoder: The transcoder to use when converting between date + /// and string values. + /// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies. + @available(*, deprecated, renamed: "init(dateTranscoder:multipartBoundaryGenerator:xmlCoder:)") @_disfavoredOverload + public init( + dateTranscoder: any DateTranscoder = .iso8601, + multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random + ) { + self.init(dateTranscoder: dateTranscoder, multipartBoundaryGenerator: multipartBoundaryGenerator, xmlCoder: nil) + } +} diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 150b804..f3c21db 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -26,6 +26,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret // Data conversion case failedToDecodeStringConvertibleValue(type: String) + case missingCoderForCustomContentType(contentType: String) enum ParameterLocation: String, CustomStringConvertible { case query @@ -88,6 +89,8 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case .invalidBase64String(let string): return "Invalid base64-encoded string (first 128 bytes): '\(string.prefix(128))'" case .failedToDecodeStringConvertibleValue(let string): return "Failed to decode a value of type '\(string)'." + case .missingCoderForCustomContentType(let contentType): + return "Missing custom coder for content type '\(contentType)'." case .unsupportedParameterStyle(name: let name, location: let location, style: let style, explode: let explode): return "Unsupported parameter style, parameter name: '\(name)', kind: \(location), style: \(style), explode: \(explode)" diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift index 4a7b669..0f3bf06 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift @@ -120,6 +120,28 @@ final class Test_ClientConverterExtensions: Test_Runtime { try await XCTAssertEqualStringifiedData(body, testStructPrettyString) XCTAssertEqual(headerFields, [.contentType: "application/json", .contentLength: "23"]) } + // | client | set | request body | XML | optional | setOptionalRequestBodyAsXML | + func test_setOptionalRequestBodyAsXML_codable() async throws { + var headerFields: HTTPFields = [:] + let body = try converter.setOptionalRequestBodyAsXML( + testStruct, + headerFields: &headerFields, + contentType: "application/xml" + ) + try await XCTAssertEqualStringifiedData(body, testStructString) + XCTAssertEqual(headerFields, [.contentType: "application/xml", .contentLength: "17"]) + } + // | client | set | request body | XML | required | setRequiredRequestBodyAsXML | + func test_setRequiredRequestBodyAsXML_codable() async throws { + var headerFields: HTTPFields = [:] + let body = try converter.setRequiredRequestBodyAsXML( + testStruct, + headerFields: &headerFields, + contentType: "application/xml" + ) + try await XCTAssertEqualStringifiedData(body, testStructString) + XCTAssertEqual(headerFields, [.contentType: "application/xml", .contentLength: "17"]) + } // | client | set | request body | urlEncodedForm | codable | optional | setRequiredRequestBodyAsURLEncodedForm | func test_setOptionalRequestBodyAsURLEncodedForm_codable() async throws { @@ -206,6 +228,15 @@ final class Test_ClientConverterExtensions: Test_Runtime { ) XCTAssertEqual(value, testStruct) } + // | client | get | response body | XML | required | getResponseBodyAsXML | + func test_getResponseBodyAsXML_codable() async throws { + let value: TestPet = try await converter.getResponseBodyAsXML( + TestPet.self, + from: .init(testStructData), + transforming: { $0 } + ) + XCTAssertEqual(value, testStruct) + } // | client | get | response body | binary | required | getResponseBodyAsBinary | func test_getResponseBodyAsBinary_data() async throws { diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index 632116b..b2305a0 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -247,6 +247,24 @@ final class Test_ServerConverterExtensions: Test_Runtime { ) XCTAssertEqual(body, testStruct) } + // | server | get | request body | XML | optional | getOptionalRequestBodyAsXML | + func test_getOptionalRequestBodyAsXML_codable() async throws { + let body: TestPet? = try await converter.getOptionalRequestBodyAsXML( + TestPet.self, + from: .init(testStructData), + transforming: { $0 } + ) + XCTAssertEqual(body, testStruct) + } + // | server | get | request body | XML | required | getRequiredRequestBodyAsXML | + func test_getRequiredRequestBodyAsXML_codable() async throws { + let body: TestPet = try await converter.getRequiredRequestBodyAsXML( + TestPet.self, + from: .init(testStructData), + transforming: { $0 } + ) + XCTAssertEqual(body, testStruct) + } // | server | get | request body | urlEncodedForm | optional | getOptionalRequestBodyAsURLEncodedForm | func test_getOptionalRequestBodyAsURLEncodedForm_codable() async throws { @@ -318,6 +336,17 @@ final class Test_ServerConverterExtensions: Test_Runtime { try await XCTAssertEqualStringifiedData(data, testStructPrettyString) XCTAssertEqual(headers, [.contentType: "application/json", .contentLength: "23"]) } + // | server | set | response body | XML | required | setResponseBodyAsXML | + func test_setResponseBodyAsXML_codable() async throws { + var headers: HTTPFields = [:] + let data = try converter.setResponseBodyAsXML( + testStruct, + headerFields: &headers, + contentType: "application/xml" + ) + try await XCTAssertEqualStringifiedData(data, testStructString) + XCTAssertEqual(headers, [.contentType: "application/xml", .contentLength: "17"]) + } // | server | set | response body | binary | required | setResponseBodyAsBinary | func test_setResponseBodyAsBinary_data() async throws { diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 9de8990..fe31067 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -26,7 +26,8 @@ class Test_Runtime: XCTestCase { var serverURL: URL { get throws { try URL(validatingOpenAPIServerURL: "/api") } } - var configuration: Configuration { .init(multipartBoundaryGenerator: .constant) } + var customCoder: any CustomCoder { MockCustomCoder() } + var configuration: Configuration { .init(multipartBoundaryGenerator: .constant, xmlCoder: customCoder) } var converter: Converter { .init(configuration: configuration) } @@ -222,6 +223,13 @@ struct MockMiddleware: ClientMiddleware, ServerMiddleware { } } +struct MockCustomCoder: CustomCoder { + func customEncode(_ value: T) throws -> Data where T: Encodable { try JSONEncoder().encode(value) } + func customDecode(_ type: T.Type, from data: Data) throws -> T where T: Decodable { + try JSONDecoder().decode(T.self, from: data) + } +} + /// Asserts that a given URL's absolute string representation is equal to an expected string. /// /// - Parameters: