From a875c2d45477be7a6d193ed3201603d05175d164 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Mon, 11 Mar 2024 08:05:15 +0000 Subject: [PATCH 1/5] Update CI to use release 5.10 (#101) --- docker/docker-compose.2204.510.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/docker-compose.2204.510.yaml b/docker/docker-compose.2204.510.yaml index 02e5d46e..079f4339 100644 --- a/docker/docker-compose.2204.510.yaml +++ b/docker/docker-compose.2204.510.yaml @@ -5,7 +5,8 @@ services: image: &image swift-openapi-runtime:22.04-5.10 build: args: - base_image: "swiftlang/swift:nightly-5.10-jammy" + ubuntu_version: "jammy" + swift_version: "5.10" test: image: *image From 634b7ebb1cd2e131a8400e3176b9f53205f8fff8 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 3 Apr 2024 10:43:08 +0200 Subject: [PATCH 2/5] Fix empty additionalProperties dictionary encoding (#103) ### Motivation Fixes https://github.com/apple/swift-openapi-generator/issues/525. Turns out that the mere act of creating a decoding container is meaningful and we skipped it as an optimization, causing JSONDecoder to fail for empty dictionaries when used in additional properties. ### Modifications Remove the extra guards that skipped creating a container, even when we already know there are no elements. ### Result No more failures when encoding empty dictionaries in additionalProperties. ### Test Plan Tested manually as this requirement seems to be coming out of JSONDecoder. --- Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift index 5aa893bf..6e9f5edc 100644 --- a/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift @@ -102,7 +102,6 @@ /// - Parameter additionalProperties: A container of additional properties. /// - Throws: An error if there are issues with encoding the additional properties. public func encodeAdditionalProperties(_ additionalProperties: OpenAPIObjectContainer) throws { - guard !additionalProperties.value.isEmpty else { return } var container = container(keyedBy: StringKey.self) for (key, value) in additionalProperties.value { try container.encode(OpenAPIValueContainer(unvalidatedValue: value), forKey: .init(key)) @@ -116,7 +115,6 @@ /// - Parameter additionalProperties: A container of additional properties. /// - Throws: An error if there are issues with encoding the additional properties. public func encodeAdditionalProperties(_ additionalProperties: [String: T]) throws { - guard !additionalProperties.isEmpty else { return } var container = container(keyedBy: StringKey.self) for (key, value) in additionalProperties { try container.encode(value, forKey: .init(key)) } } From 6e2f9b9787a2ba597fd97960bfa65ff101bf79b8 Mon Sep 17 00:00:00 2001 From: Ugo Cottin Date: Fri, 5 Apr 2024 08:31:34 +0200 Subject: [PATCH 3/5] Add support for application/xml body (#102) ### Motivation See [apple/swift-openapi-generator#556](https://github.com/apple/swift-openapi-generator/issues/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 Co-authored-by: Honza Dvorsky --- .../OpenAPIRuntime/Base/OpenAPIMIMEType.swift | 3 + .../Conversion/Configuration.swift | 29 +++++++- .../Conversion/Converter+Client.swift | 67 +++++++++++++++++++ .../Conversion/Converter+Server.swift | 59 ++++++++++++++++ .../Conversion/CurrencyExtensions.swift | 26 +++++++ .../Deprecated/Deprecated.swift | 16 +++++ .../OpenAPIRuntime/Errors/RuntimeError.swift | 3 + .../Conversion/Test_Converter+Client.swift | 31 +++++++++ .../Conversion/Test_Converter+Server.swift | 29 ++++++++ Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 10 ++- 10 files changed, 271 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift index 6dc2a730..d7092b1d 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 e0b593a5..f5ca02be 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 ea575002..28abbdb2 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 e8f36306..75b0f521 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 38a17115..ea07d217 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 bf030d1c..1cfa5c99 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 150b804c..f3c21db9 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 4a7b669c..0f3bf066 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 632116b2..b2305a08 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 9de89902..fe31067e 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: From 9d4a2ffb98c1bb6a2a037b267a4ef0eec0654059 Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Tue, 9 Apr 2024 05:37:15 -0400 Subject: [PATCH 4/5] Improve error message when encountering unexpected content-type headers (#104) ### Motivation When the server response has a content-type header that does not conform to the OpenAPI document, we currently throw an error. However, this error presents itself in a very confusing manner: it prints `Unexpected Content-Type header: application/json`, where `application/json is the _expected_ content-type. At best, this is ambiguous and potentially misleading. ### Modifications - Extend `case RuntimeError.unexpectedContentTypeHeader` with _both_ the expected and received content-type associated values. - Update the printed description to include both the expected and received content-type header values. ### Result When an unexpected content-type is received, the error message is clearer. ### Test Plan Updated the existing test that expects an error to check the error and that the error values are provided in the correct order. --- .../Conversion/Converter+Common.swift | 7 ++++++- Sources/OpenAPIRuntime/Errors/RuntimeError.swift | 5 +++-- .../Conversion/Test_Converter+Common.swift | 13 +++++++++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift index dc908e75..73f8fecb 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift @@ -61,7 +61,12 @@ extension Converter { // The force unwrap is safe, we only get here if the array is not empty. let bestOption = evaluatedOptions.max { a, b in a.match.score < b.match.score }! let bestContentType = bestOption.contentType - if case .incompatible = bestOption.match { throw RuntimeError.unexpectedContentTypeHeader(bestContentType) } + if case .incompatible = bestOption.match { + throw RuntimeError.unexpectedContentTypeHeader( + expected: bestContentType, + received: String(describing: received) + ) + } return bestContentType } diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index f3c21db9..b0c776ed 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -37,7 +37,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret // Headers case missingRequiredHeaderField(String) - case unexpectedContentTypeHeader(String) + case unexpectedContentTypeHeader(expected: String, received: String) case unexpectedAcceptHeader(String) case malformedAcceptHeader(String) case missingOrMalformedContentDispositionName @@ -95,7 +95,8 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret return "Unsupported parameter style, parameter name: '\(name)', kind: \(location), style: \(style), explode: \(explode)" case .missingRequiredHeaderField(let name): return "The required header field named '\(name)' is missing." - case .unexpectedContentTypeHeader(let contentType): return "Unexpected Content-Type header: \(contentType)" + case .unexpectedContentTypeHeader(expected: let expected, received: let received): + return "Unexpected content type, expected: \(expected), received: \(received)" case .unexpectedAcceptHeader(let accept): return "Unexpected Accept header: \(accept)" case .malformedAcceptHeader(let accept): return "Malformed Accept header: \(accept)" case .missingOrMalformedContentDispositionName: diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index 925ebf4f..e82b12ab 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// import XCTest -@_spi(Generated) import OpenAPIRuntime +@testable @_spi(Generated) import OpenAPIRuntime import HTTPTypes extension HTTPField.Name { static var foo: Self { Self("foo")! } } @@ -84,7 +84,16 @@ final class Test_CommonConverterExtensions: Test_Runtime { try testCase(received: "image/png", options: ["image/*", "*/*"], expected: "image/*") XCTAssertThrowsError( try testCase(received: "text/csv", options: ["text/html", "application/json"], expected: "-") - ) + ) { error in + XCTAssert(error is RuntimeError) + guard let error = error as? RuntimeError, + case .unexpectedContentTypeHeader(expected: let expected, received: let received) = error, + expected == "text/html", received == "text/csv" + else { + XCTFail("Unexpected error: \(error)") + return + } + } } func testVerifyContentTypeIfPresent() throws { From 9a8291fa2f90cc7296f2393a99bb4824ee34f869 Mon Sep 17 00:00:00 2001 From: kostis stefanou Date: Tue, 16 Apr 2024 12:51:05 +0300 Subject: [PATCH 5/5] [Runtime] Add support of deepObject style in query params (#100) ### Motivation The runtime changes for: https://github.com/apple/swift-openapi-generator/issues/259 ### Modifications Added `deepObject` style to serializer & parser in order to support nested keys on query parameters. ### Result Support nested keys on query parameters. ### Test Plan These are just the runtime changes, tested together with generated changes. --------- Co-authored-by: Honza Dvorsky --- .../Conversion/CurrencyExtensions.swift | 4 +- .../Conversion/ParameterStyles.swift | 5 + .../Common/URICoderConfiguration.swift | 2 + .../URICoder/Parsing/URIParser.swift | 43 ++++++- .../Serialization/URISerializer.swift | 26 +++- .../URICoder/Encoding/Test_URIEncoder.swift | 7 ++ .../URICoder/Parsing/Test_URIParser.swift | 65 +++++++--- .../Serialization/Test_URISerializer.swift | 97 ++++++++++---- .../URICoder/Test_URICodingRoundtrip.swift | 118 +++++++++++++----- .../URICoder/URICoderTestUtils.swift | 6 + 10 files changed, 300 insertions(+), 73 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift index ea07d217..fc50b2a1 100644 --- a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift @@ -29,7 +29,9 @@ extension ParameterStyle { ) { let resolvedStyle = style ?? .defaultForQueryItems let resolvedExplode = explode ?? ParameterStyle.defaultExplodeFor(forStyle: resolvedStyle) - guard resolvedStyle == .form else { + switch resolvedStyle { + case .form, .deepObject: break + default: throw RuntimeError.unsupportedParameterStyle( name: name, location: .query, diff --git a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift index fb95bce7..07aa6092 100644 --- a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift +++ b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift @@ -26,6 +26,10 @@ /// /// Details: https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.2 case simple + /// The deepObject style. + /// + /// Details: https://spec.openapis.org/oas/v3.1.0.html#style-values + case deepObject } extension ParameterStyle { @@ -53,6 +57,7 @@ extension URICoderConfiguration.Style { switch style { case .form: self = .form case .simple: self = .simple + case .deepObject: self = .deepObject } } } diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift b/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift index bfb42c48..ccbdb8c5 100644 --- a/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift +++ b/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift @@ -25,6 +25,8 @@ struct URICoderConfiguration { /// A style for form-based URI expansion. case form + /// A style for nested variable expansion + case deepObject } /// A character used to escape the space character. diff --git a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift index 3be75420..c1cb5940 100644 --- a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift +++ b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift @@ -36,13 +36,15 @@ struct URIParser: Sendable { } /// A typealias for the underlying raw string storage. -private typealias Raw = String.SubSequence +typealias Raw = String.SubSequence /// A parser error. -private enum ParsingError: Swift.Error { +enum ParsingError: Swift.Error, Hashable { /// A malformed key-value pair was detected. case malformedKeyValuePair(Raw) + /// An invalid configuration was detected. + case invalidConfiguration(String) } // MARK: - Parser implementations @@ -61,6 +63,7 @@ extension URIParser { switch configuration.style { case .form: return [:] case .simple: return ["": [""]] + case .deepObject: return [:] } } switch (configuration.style, configuration.explode) { @@ -68,6 +71,10 @@ extension URIParser { case (.form, false): return try parseUnexplodedFormRoot() case (.simple, true): return try parseExplodedSimpleRoot() case (.simple, false): return try parseUnexplodedSimpleRoot() + case (.deepObject, true): return try parseExplodedDeepObjectRoot() + case (.deepObject, false): + let reason = "Deep object style is only valid with explode set to true" + throw ParsingError.invalidConfiguration(reason) } } @@ -205,6 +212,38 @@ extension URIParser { } } } + /// Parses the root node assuming the raw string uses the deepObject style + /// and the explode parameter is enabled. + /// - Returns: The parsed root node. + /// - Throws: An error if parsing fails. + private mutating func parseExplodedDeepObjectRoot() throws -> URIParsedNode { + let parseNode = try parseGenericRoot { data, appendPair in + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + let nestedKeyStartingCharacter: Character = "[" + let nestedKeyEndingCharacter: Character = "]" + func nestedKey(from deepObjectKey: String.SubSequence) -> Raw { + var unescapedDeepObjectKey = Substring(deepObjectKey.removingPercentEncoding ?? "") + let topLevelKey = unescapedDeepObjectKey.parseUpToCharacterOrEnd(nestedKeyStartingCharacter) + let nestedKey = unescapedDeepObjectKey.parseUpToCharacterOrEnd(nestedKeyEndingCharacter) + return nestedKey.isEmpty ? topLevelKey : nestedKey + } + while !data.isEmpty { + let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( + first: keyValueSeparator, + second: pairSeparator + ) + guard case .foundFirst = firstResult else { throw ParsingError.malformedKeyValuePair(firstValue) } + // Hit the key/value separator, so a value will follow. + let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) + let key = nestedKey(from: firstValue) + let value = secondValue + appendPair(key, [value]) + } + } + for (key, value) in parseNode where value.count > 1 { throw ParsingError.malformedKeyValuePair(key) } + return parseNode + } } // MARK: - URIParser utilities diff --git a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift index 26071f85..45d3b0da 100644 --- a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift +++ b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift @@ -65,10 +65,16 @@ extension CharacterSet { extension URISerializer { /// A serializer error. - private enum SerializationError: Swift.Error { + enum SerializationError: Swift.Error, Hashable { /// Nested containers are not supported. case nestedContainersNotSupported + /// Deep object arrays are not supported. + case deepObjectsArrayNotSupported + /// Deep object with primitive values are not supported. + case deepObjectsWithPrimitiveValuesNotSupported + /// An invalid configuration was detected. + case invalidConfiguration(String) } /// Computes an escaped version of the provided string. @@ -117,6 +123,7 @@ extension URISerializer { switch configuration.style { case .form: keyAndValueSeparator = "=" case .simple: keyAndValueSeparator = nil + case .deepObject: throw SerializationError.deepObjectsWithPrimitiveValuesNotSupported } try serializePrimitiveKeyValuePair(primitive, forKey: key, separator: keyAndValueSeparator) case .array(let array): try serializeArray(array.map(unwrapPrimitiveValue), forKey: key) @@ -180,6 +187,7 @@ extension URISerializer { case (.simple, _): keyAndValueSeparator = nil pairSeparator = "," + case (.deepObject, _): throw SerializationError.deepObjectsArrayNotSupported } func serializeNext(_ element: URIEncodedNode.Primitive) throws { if let keyAndValueSeparator { @@ -228,8 +236,18 @@ extension URISerializer { case (.simple, false): keyAndValueSeparator = "," pairSeparator = "," + case (.deepObject, true): + keyAndValueSeparator = "=" + pairSeparator = "&" + case (.deepObject, false): + let reason = "Deep object style is only valid with explode set to true" + throw SerializationError.invalidConfiguration(reason) } + func serializeNestedKey(_ elementKey: String, forKey rootKey: String) -> String { + guard case .deepObject = configuration.style else { return elementKey } + return rootKey + "[" + elementKey + "]" + } func serializeNext(_ element: URIEncodedNode.Primitive, forKey elementKey: String) throws { try serializePrimitiveKeyValuePair(element, forKey: elementKey, separator: keyAndValueSeparator) } @@ -238,10 +256,12 @@ extension URISerializer { data.append(containerKeyAndValue) } for (elementKey, element) in sortedDictionary.dropLast() { - try serializeNext(element, forKey: elementKey) + try serializeNext(element, forKey: serializeNestedKey(elementKey, forKey: key)) data.append(pairSeparator) } - if let (elementKey, element) = sortedDictionary.last { try serializeNext(element, forKey: elementKey) } + if let (elementKey, element) = sortedDictionary.last { + try serializeNext(element, forKey: serializeNestedKey(elementKey, forKey: key)) + } } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift index fe9d445e..fd5cdd20 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift @@ -23,4 +23,11 @@ final class Test_URIEncoder: Test_Runtime { let encodedString = try encoder.encode(Foo(bar: "hello world"), forKey: "root") XCTAssertEqual(encodedString, "bar=hello+world") } + func testNestedEncoding() throws { + struct Foo: Encodable { var bar: String } + let serializer = URISerializer(configuration: .deepObjectExplode) + let encoder = URIEncoder(serializer: serializer) + let encodedString = try encoder.encode(Foo(bar: "hello world"), forKey: "root") + XCTAssertEqual(encodedString, "root%5Bbar%5D=hello%20world") + } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift index 9bd8f3e8..86c962e1 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift @@ -18,6 +18,7 @@ final class Test_URIParser: Test_Runtime { let testedVariants: [URICoderConfiguration] = [ .formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode, + .deepObjectExplode, ] func testParsing() throws { @@ -29,7 +30,8 @@ final class Test_URIParser: Test_Runtime { simpleExplode: .custom("", value: ["": [""]]), simpleUnexplode: .custom("", value: ["": [""]]), formDataExplode: "empty=", - formDataUnexplode: "empty=" + formDataUnexplode: "empty=", + deepObjectExplode: "object%5Bempty%5D=" ), value: ["empty": [""]] ), @@ -40,7 +42,8 @@ final class Test_URIParser: Test_Runtime { simpleExplode: .custom("", value: ["": [""]]), simpleUnexplode: .custom("", value: ["": [""]]), formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: "" ), value: [:] ), @@ -51,7 +54,8 @@ final class Test_URIParser: Test_Runtime { simpleExplode: .custom("fred", value: ["": ["fred"]]), simpleUnexplode: .custom("fred", value: ["": ["fred"]]), formDataExplode: "who=fred", - formDataUnexplode: "who=fred" + formDataUnexplode: "who=fred", + deepObjectExplode: "object%5Bwho%5D=fred" ), value: ["who": ["fred"]] ), @@ -62,7 +66,8 @@ final class Test_URIParser: Test_Runtime { simpleExplode: .custom("Hello%20World", value: ["": ["Hello World"]]), simpleUnexplode: .custom("Hello%20World", value: ["": ["Hello World"]]), formDataExplode: "hello=Hello+World", - formDataUnexplode: "hello=Hello+World" + formDataUnexplode: "hello=Hello+World", + deepObjectExplode: "object%5Bhello%5D=Hello%20World" ), value: ["hello": ["Hello World"]] ), @@ -73,7 +78,11 @@ final class Test_URIParser: Test_Runtime { simpleExplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]), simpleUnexplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]), formDataExplode: "list=red&list=green&list=blue", - formDataUnexplode: "list=red,green,blue" + formDataUnexplode: "list=red,green,blue", + deepObjectExplode: .custom( + "object%5Blist%5D=red&object%5Blist%5D=green&object%5Blist%5D=blue", + expectedError: .malformedKeyValuePair("list") + ) ), value: ["list": ["red", "green", "blue"]] ), @@ -93,7 +102,8 @@ final class Test_URIParser: Test_Runtime { formDataUnexplode: .custom( "keys=comma,%2C,dot,.,semi,%3B", value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]] - ) + ), + deepObjectExplode: "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Bsemi%5D=%3B" ), value: ["semi": [";"], "dot": ["."], "comma": [","]] ), @@ -101,14 +111,28 @@ final class Test_URIParser: Test_Runtime { for testCase in cases { func testVariant(_ variant: Case.Variant, _ input: Case.Variants.Input) throws { var parser = URIParser(configuration: variant.config, data: input.string[...]) - let parsedNode = try parser.parseRoot() - XCTAssertEqual( - parsedNode, - input.valueOverride ?? testCase.value, - "Failed for config: \(variant.name)", - file: testCase.file, - line: testCase.line - ) + do { + let parsedNode = try parser.parseRoot() + XCTAssertEqual( + parsedNode, + input.valueOverride ?? testCase.value, + "Failed for config: \(variant.name)", + file: testCase.file, + line: testCase.line + ) + } catch { + guard let expectedError = input.expectedError, let parsingError = error as? ParsingError else { + XCTAssert(false, "Unexpected error thrown: \(error)", file: testCase.file, line: testCase.line) + return + } + XCTAssertEqual( + expectedError, + parsingError, + "Failed for config: \(variant.name)", + file: testCase.file, + line: testCase.line + ) + } } let variants = testCase.variants try testVariant(.formExplode, variants.formExplode) @@ -117,6 +141,7 @@ final class Test_URIParser: Test_Runtime { try testVariant(.simpleUnexplode, variants.simpleUnexplode) try testVariant(.formDataExplode, variants.formDataExplode) try testVariant(.formDataUnexplode, variants.formDataUnexplode) + try testVariant(.deepObjectExplode, variants.deepObjectExplode) } } } @@ -133,25 +158,32 @@ extension Test_URIParser { static let simpleUnexplode: Self = .init(name: "simpleUnexplode", config: .simpleUnexplode) static let formDataExplode: Self = .init(name: "formDataExplode", config: .formDataExplode) static let formDataUnexplode: Self = .init(name: "formDataUnexplode", config: .formDataUnexplode) + static let deepObjectExplode: Self = .init(name: "deepObjectExplode", config: .deepObjectExplode) } struct Variants { struct Input: ExpressibleByStringLiteral { var string: String var valueOverride: URIParsedNode? + var expectedError: ParsingError? - init(string: String, valueOverride: URIParsedNode? = nil) { + init(string: String, valueOverride: URIParsedNode? = nil, expectedError: ParsingError? = nil) { self.string = string self.valueOverride = valueOverride + self.expectedError = expectedError } static func custom(_ string: String, value: URIParsedNode) -> Self { - .init(string: string, valueOverride: value) + .init(string: string, valueOverride: value, expectedError: nil) + } + static func custom(_ string: String, expectedError: ParsingError) -> Self { + .init(string: string, valueOverride: nil, expectedError: expectedError) } init(stringLiteral value: String) { self.string = value self.valueOverride = nil + self.expectedError = nil } } @@ -161,6 +193,7 @@ extension Test_URIParser { var simpleUnexplode: Input var formDataExplode: Input var formDataUnexplode: Input + var deepObjectExplode: Input } var variants: Variants var value: URIParsedNode diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift index f93fabed..688c508a 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift @@ -31,7 +31,8 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "", simpleUnexplode: "", formDataExplode: "empty=", - formDataUnexplode: "empty=" + formDataUnexplode: "empty=", + deepObjectExplode: .custom("empty=", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ), makeCase( @@ -43,7 +44,8 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "fred", simpleUnexplode: "fred", formDataExplode: "who=fred", - formDataUnexplode: "who=fred" + formDataUnexplode: "who=fred", + deepObjectExplode: .custom("who=fred", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ), makeCase( @@ -55,7 +57,8 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "1234", simpleUnexplode: "1234", formDataExplode: "x=1234", - formDataUnexplode: "x=1234" + formDataUnexplode: "x=1234", + deepObjectExplode: .custom("x=1234", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ), makeCase( @@ -67,7 +70,8 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "12.34", simpleUnexplode: "12.34", formDataExplode: "x=12.34", - formDataUnexplode: "x=12.34" + formDataUnexplode: "x=12.34", + deepObjectExplode: .custom("x=12.34", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ), makeCase( @@ -79,7 +83,11 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "true", simpleUnexplode: "true", formDataExplode: "enabled=true", - formDataUnexplode: "enabled=true" + formDataUnexplode: "enabled=true", + deepObjectExplode: .custom( + "enabled=true", + expectedError: .deepObjectsWithPrimitiveValuesNotSupported + ) ) ), makeCase( @@ -91,7 +99,11 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "Hello%20World", simpleUnexplode: "Hello%20World", formDataExplode: "hello=Hello+World", - formDataUnexplode: "hello=Hello+World" + formDataUnexplode: "hello=Hello+World", + deepObjectExplode: .custom( + "hello=Hello%20World", + expectedError: .deepObjectsWithPrimitiveValuesNotSupported + ) ) ), makeCase( @@ -103,7 +115,11 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "red,green,blue", simpleUnexplode: "red,green,blue", formDataExplode: "list=red&list=green&list=blue", - formDataUnexplode: "list=red,green,blue" + formDataUnexplode: "list=red,green,blue", + deepObjectExplode: .custom( + "list=red&list=green&list=blue", + expectedError: .deepObjectsArrayNotSupported + ) ) ), makeCase( @@ -118,21 +134,38 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "comma=%2C,dot=.,semi=%3B", simpleUnexplode: "comma,%2C,dot,.,semi,%3B", formDataExplode: "comma=%2C&dot=.&semi=%3B", - formDataUnexplode: "keys=comma,%2C,dot,.,semi,%3B" + formDataUnexplode: "keys=comma,%2C,dot,.,semi,%3B", + deepObjectExplode: "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Bsemi%5D=%3B" ) ), ] for testCase in cases { - func testVariant(_ variant: Case.Variant, _ expectedString: String) throws { + func testVariant(_ variant: Case.Variant, _ input: Case.Variants.Input) throws { var serializer = URISerializer(configuration: variant.config) - let encodedString = try serializer.serializeNode(testCase.value, forKey: testCase.key) - XCTAssertEqual( - encodedString, - expectedString, - "Failed for config: \(variant.name)", - file: testCase.file, - line: testCase.line - ) + do { + let encodedString = try serializer.serializeNode(testCase.value, forKey: testCase.key) + XCTAssertEqual( + encodedString, + input.string, + "Failed for config: \(variant.name)", + file: testCase.file, + line: testCase.line + ) + } catch { + guard let expectedError = input.expectedError, + let serializationError = error as? URISerializer.SerializationError + else { + XCTAssert(false, "Unexpected error thrown: \(error)", file: testCase.file, line: testCase.line) + return + } + XCTAssertEqual( + expectedError, + serializationError, + "Failed for config: \(variant.name)", + file: testCase.file, + line: testCase.line + ) + } } try testVariant(.formExplode, testCase.variants.formExplode) try testVariant(.formUnexplode, testCase.variants.formUnexplode) @@ -140,6 +173,7 @@ final class Test_URISerializer: Test_Runtime { try testVariant(.simpleUnexplode, testCase.variants.simpleUnexplode) try testVariant(.formDataExplode, testCase.variants.formDataExplode) try testVariant(.formDataUnexplode, testCase.variants.formDataUnexplode) + try testVariant(.deepObjectExplode, testCase.variants.deepObjectExplode) } } } @@ -156,14 +190,31 @@ extension Test_URISerializer { static let simpleUnexplode: Self = .init(name: "simpleUnexplode", config: .simpleUnexplode) static let formDataExplode: Self = .init(name: "formDataExplode", config: .formDataExplode) static let formDataUnexplode: Self = .init(name: "formDataUnexplode", config: .formDataUnexplode) + static let deepObjectExplode: Self = .init(name: "deepObjectExplode", config: .deepObjectExplode) } struct Variants { - var formExplode: String - var formUnexplode: String - var simpleExplode: String - var simpleUnexplode: String - var formDataExplode: String - var formDataUnexplode: String + struct Input: ExpressibleByStringLiteral { + var string: String + var expectedError: URISerializer.SerializationError? + init(string: String, expectedError: URISerializer.SerializationError? = nil) { + self.string = string + self.expectedError = expectedError + } + static func custom(_ string: String, expectedError: URISerializer.SerializationError) -> Self { + .init(string: string, expectedError: expectedError) + } + init(stringLiteral value: String) { + self.string = value + self.expectedError = nil + } + } + var formExplode: Input + var formUnexplode: Input + var simpleExplode: Input + var simpleUnexplode: Input + var formDataExplode: Input + var formDataUnexplode: Input + var deepObjectExplode: Input } var value: URIEncodedNode var key: String diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift index ccfe52c4..cc0dc29c 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift @@ -96,7 +96,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "", simpleUnexplode: "", formDataExplode: "root=", - formDataUnexplode: "root=" + formDataUnexplode: "root=", + deepObjectExplode: .custom("root=", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -110,7 +111,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "Hello%20World%21", simpleUnexplode: "Hello%20World%21", formDataExplode: "root=Hello+World%21", - formDataUnexplode: "root=Hello+World%21" + formDataUnexplode: "root=Hello+World%21", + deepObjectExplode: .custom( + "root=Hello%20World%21", + expectedError: .deepObjectsWithPrimitiveValuesNotSupported + ) ) ) @@ -124,7 +129,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "red", simpleUnexplode: "red", formDataExplode: "root=red", - formDataUnexplode: "root=red" + formDataUnexplode: "root=red", + deepObjectExplode: .custom("root=red", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -138,7 +144,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "1234", simpleUnexplode: "1234", formDataExplode: "root=1234", - formDataUnexplode: "root=1234" + formDataUnexplode: "root=1234", + deepObjectExplode: .custom("root=1234", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -152,7 +159,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "12.34", simpleUnexplode: "12.34", formDataExplode: "root=12.34", - formDataUnexplode: "root=12.34" + formDataUnexplode: "root=12.34", + deepObjectExplode: .custom("root=12.34", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -166,7 +174,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "true", simpleUnexplode: "true", formDataExplode: "root=true", - formDataUnexplode: "root=true" + formDataUnexplode: "root=true", + deepObjectExplode: .custom("root=true", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -180,7 +189,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "2023-08-25T07%3A34%3A59Z", simpleUnexplode: "2023-08-25T07%3A34%3A59Z", formDataExplode: "root=2023-08-25T07%3A34%3A59Z", - formDataUnexplode: "root=2023-08-25T07%3A34%3A59Z" + formDataUnexplode: "root=2023-08-25T07%3A34%3A59Z", + deepObjectExplode: .custom( + "root=2023-08-25T07%3A34%3A59Z", + expectedError: .deepObjectsWithPrimitiveValuesNotSupported + ) ) ) @@ -194,7 +207,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "a,b,c", simpleUnexplode: "a,b,c", formDataExplode: "list=a&list=b&list=c", - formDataUnexplode: "list=a,b,c" + formDataUnexplode: "list=a,b,c", + deepObjectExplode: .custom("list=a&list=b&list=c", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -208,7 +222,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z", simpleUnexplode: "2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z", formDataExplode: "list=2023-08-25T07%3A34%3A59Z&list=2023-08-25T07%3A35%3A01Z", - formDataUnexplode: "list=2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z" + formDataUnexplode: "list=2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z", + deepObjectExplode: .custom( + "list=2023-08-25T07%3A34%3A59Z&list=2023-08-25T07%3A35%3A01Z", + expectedError: .deepObjectsArrayNotSupported + ) ) ) @@ -222,7 +240,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: .custom("", value: [""]), simpleUnexplode: .custom("", value: [""]), formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: .custom("", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -236,7 +255,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "red,green,blue", simpleUnexplode: "red,green,blue", formDataExplode: "list=red&list=green&list=blue", - formDataUnexplode: "list=red,green,blue" + formDataUnexplode: "list=red,green,blue", + deepObjectExplode: .custom( + "list=red&list=green&list=blue", + expectedError: .deepObjectsArrayNotSupported + ) ) ) @@ -250,7 +273,9 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "bar=24,color=red,date=2023-08-25T07%3A34%3A59Z,empty=,foo=hi%21", simpleUnexplode: "bar,24,color,red,date,2023-08-25T07%3A34%3A59Z,empty,,foo,hi%21", formDataExplode: "bar=24&color=red&date=2023-08-25T07%3A34%3A59Z&empty=&foo=hi%21", - formDataUnexplode: "keys=bar,24,color,red,date,2023-08-25T07%3A34%3A59Z,empty,,foo,hi%21" + formDataUnexplode: "keys=bar,24,color,red,date,2023-08-25T07%3A34%3A59Z,empty,,foo,hi%21", + deepObjectExplode: + "keys%5Bbar%5D=24&keys%5Bcolor%5D=red&keys%5Bdate%5D=2023-08-25T07%3A34%3A59Z&keys%5Bempty%5D=&keys%5Bfoo%5D=hi%21" ) ) @@ -265,7 +290,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "2023-01-18T10%3A04%3A11Z", simpleUnexplode: "2023-01-18T10%3A04%3A11Z", formDataExplode: "root=2023-01-18T10%3A04%3A11Z", - formDataUnexplode: "root=2023-01-18T10%3A04%3A11Z" + formDataUnexplode: "root=2023-01-18T10%3A04%3A11Z", + deepObjectExplode: .custom( + "root=2023-01-18T10%3A04%3A11Z", + expectedError: .deepObjectsWithPrimitiveValuesNotSupported + ) ) ) try _test( @@ -277,7 +306,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "green", simpleUnexplode: "green", formDataExplode: "root=green", - formDataUnexplode: "root=green" + formDataUnexplode: "root=green", + deepObjectExplode: .custom("root=green", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) try _test( @@ -289,7 +319,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "foo=bar", simpleUnexplode: "foo,bar", formDataExplode: "foo=bar", - formDataUnexplode: "root=foo,bar" + formDataUnexplode: "root=foo,bar", + deepObjectExplode: "root%5Bfoo%5D=bar" ) ) @@ -304,7 +335,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "", simpleUnexplode: "", formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: "" ) ) @@ -318,7 +350,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "bar=24,color=red,empty=,foo=hi%21", simpleUnexplode: "bar,24,color,red,empty,,foo,hi%21", formDataExplode: "bar=24&color=red&empty=&foo=hi%21", - formDataUnexplode: "keys=bar,24,color,red,empty,,foo,hi%21" + formDataUnexplode: "keys=bar,24,color,red,empty,,foo,hi%21", + deepObjectExplode: "keys%5Bbar%5D=24&keys%5Bcolor%5D=red&keys%5Bempty%5D=&keys%5Bfoo%5D=hi%21" ) ) @@ -332,7 +365,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: .custom("", value: ["": ""]), simpleUnexplode: .custom("", value: ["": ""]), formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: "" ) ) } @@ -347,21 +381,28 @@ final class Test_URICodingRoundtrip: Test_Runtime { static let simpleUnexplode: Self = .init(name: "simpleUnexplode", configuration: .simpleUnexplode) static let formDataExplode: Self = .init(name: "formDataExplode", configuration: .formDataExplode) static let formDataUnexplode: Self = .init(name: "formDataUnexplode", configuration: .formDataUnexplode) + static let deepObjectExplode: Self = .init(name: "deepObjectExplode", configuration: .deepObjectExplode) } struct Variants { struct Input: ExpressibleByStringLiteral { var string: String var customValue: T? - - init(string: String, customValue: T?) { + var expectedError: URISerializer.SerializationError? + init(string: String, customValue: T?, expectedError: URISerializer.SerializationError?) { self.string = string self.customValue = customValue + self.expectedError = expectedError } - init(stringLiteral value: String) { self.init(string: value, customValue: nil) } + init(stringLiteral value: String) { self.init(string: value, customValue: nil, expectedError: nil) } - static func custom(_ string: String, value: T) -> Self { .init(string: string, customValue: value) } + static func custom(_ string: String, value: T) -> Self { + .init(string: string, customValue: value, expectedError: nil) + } + static func custom(_ string: String, expectedError: URISerializer.SerializationError) -> Self { + .init(string: string, customValue: nil, expectedError: expectedError) + } } var formExplode: Input @@ -370,6 +411,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { var simpleUnexplode: Input var formDataExplode: Input var formDataUnexplode: Input + var deepObjectExplode: Input } func _test( @@ -381,11 +423,27 @@ final class Test_URICodingRoundtrip: Test_Runtime { ) throws { func testVariant(name: String, configuration: URICoderConfiguration, variant: Variants.Input) throws { let encoder = URIEncoder(configuration: configuration) - let encodedString = try encoder.encode(value, forKey: key) - XCTAssertEqual(encodedString, variant.string, "Variant: \(name)", file: file, line: line) - let decoder = URIDecoder(configuration: configuration) - let decodedValue = try decoder.decode(T.self, forKey: key, from: encodedString[...]) - XCTAssertEqual(decodedValue, variant.customValue ?? value, "Variant: \(name)", file: file, line: line) + do { + let encodedString = try encoder.encode(value, forKey: key) + XCTAssertEqual(encodedString, variant.string, "Variant: \(name)", file: file, line: line) + let decoder = URIDecoder(configuration: configuration) + let decodedValue = try decoder.decode(T.self, forKey: key, from: encodedString[...]) + XCTAssertEqual(decodedValue, variant.customValue ?? value, "Variant: \(name)", file: file, line: line) + } catch { + guard let expectedError = variant.expectedError, + let serializationError = error as? URISerializer.SerializationError + else { + XCTAssert(false, "Unexpected error thrown: \(error)", file: file, line: line) + return + } + XCTAssertEqual( + expectedError, + serializationError, + "Failed for config: \(variant.string)", + file: file, + line: line + ) + } } try testVariant(name: "formExplode", configuration: .formExplode, variant: variants.formExplode) try testVariant(name: "formUnexplode", configuration: .formUnexplode, variant: variants.formUnexplode) @@ -397,6 +455,10 @@ final class Test_URICodingRoundtrip: Test_Runtime { configuration: .formDataUnexplode, variant: variants.formDataUnexplode ) + try testVariant( + name: "deepObjectExplode", + configuration: .deepObjectExplode, + variant: variants.deepObjectExplode + ) } - } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift b/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift index 375c266a..65235d82 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift @@ -59,4 +59,10 @@ extension URICoderConfiguration { spaceEscapingCharacter: .plus, dateTranscoder: defaultDateTranscoder ) + static let deepObjectExplode: Self = .init( + style: .deepObject, + explode: true, + spaceEscapingCharacter: .percentEncoded, + dateTranscoder: defaultDateTranscoder + ) }