From 9a8291fa2f90cc7296f2393a99bb4824ee34f869 Mon Sep 17 00:00:00 2001 From: kostis stefanou Date: Tue, 16 Apr 2024 12:51:05 +0300 Subject: [PATCH] [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 + ) }