Skip to content

Commit

Permalink
Add support for application/xml body (#102)
Browse files Browse the repository at this point in the history
### Motivation

See
[apple/swift-openapi-generator#556](apple/swift-openapi-generator#556)
for more.

### Modifications

Add converter methods for encoding and decoding XML request and response
body.
Add `CustomCoder` protocol, allows to use other `Encoder` and `Decoder`
for other `content-type` body.

User can define custom coder, and assign a custom coder to a specific
content-type within `Configuration.customCoders` dictionary.

### Result

It's now possible to define custom encoder and decoder for supported
content-type.

### Test Plan

Added converter methods are tested with a mock custom coder for
`application/xml` content-type. To avoid adding a dependency to a
XMLCoder like
[CoreOffice/XMLCoder](https://github.com/CoreOffice/XMLCoder), mock
custom coder uses JSONEncoder and JSONDecoder.

Encoding and decoding to XML are out of scope of the tests, because
encoding and decoding logic must be provided by user through custom
coder implementation.

---------

Signed-off-by: Ugo Cottin <[email protected]>
Co-authored-by: Honza Dvorsky <[email protected]>
  • Loading branch information
ugocottin and czechboy0 authored Apr 5, 2024
1 parent 634b7eb commit 6e2f9b9
Show file tree
Hide file tree
Showing 10 changed files with 271 additions and 2 deletions.
3 changes: 3 additions & 0 deletions Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
29 changes: 28 additions & 1 deletion Sources/OpenAPIRuntime/Conversion/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Encodable>(_ 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<T: Decodable>(_ 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 {

Expand All @@ -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
}
}
67 changes: 67 additions & 0 deletions Sources/OpenAPIRuntime/Conversion/Converter+Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Encodable>(
_ 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<T: Encodable>(
_ 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`.
///
Expand Down Expand Up @@ -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<T: Decodable, C>(
_ 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.
///
Expand Down
59 changes: 59 additions & 0 deletions Sources/OpenAPIRuntime/Conversion/Converter+Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Decodable, C>(
_ 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<T: Decodable, C>(
_ 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:
Expand Down Expand Up @@ -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<T: Encodable>(_ 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.
///
Expand Down
26 changes: 26 additions & 0 deletions Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Decodable>(_ 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<T: Encodable>(_ 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.
Expand Down
16 changes: 16 additions & 0 deletions Sources/OpenAPIRuntime/Deprecated/Deprecated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
3 changes: 3 additions & 0 deletions Sources/OpenAPIRuntime/Errors/RuntimeError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)"
Expand Down
31 changes: 31 additions & 0 deletions Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion Tests/OpenAPIRuntimeTests/Test_Runtime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down Expand Up @@ -222,6 +223,13 @@ struct MockMiddleware: ClientMiddleware, ServerMiddleware {
}
}

struct MockCustomCoder: CustomCoder {
func customEncode<T>(_ value: T) throws -> Data where T: Encodable { try JSONEncoder().encode(value) }
func customDecode<T>(_ 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:
Expand Down

0 comments on commit 6e2f9b9

Please sign in to comment.