Skip to content

Commit 9a8291f

Browse files
[Runtime] Add support of deepObject style in query params (#100)
### Motivation The runtime changes for: apple/swift-openapi-generator#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 <[email protected]>
1 parent 9d4a2ff commit 9a8291f

File tree

10 files changed

+300
-73
lines changed

10 files changed

+300
-73
lines changed

Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift

+3-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ extension ParameterStyle {
2929
) {
3030
let resolvedStyle = style ?? .defaultForQueryItems
3131
let resolvedExplode = explode ?? ParameterStyle.defaultExplodeFor(forStyle: resolvedStyle)
32-
guard resolvedStyle == .form else {
32+
switch resolvedStyle {
33+
case .form, .deepObject: break
34+
default:
3335
throw RuntimeError.unsupportedParameterStyle(
3436
name: name,
3537
location: .query,

Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift

+5
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
///
2727
/// Details: https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.2
2828
case simple
29+
/// The deepObject style.
30+
///
31+
/// Details: https://spec.openapis.org/oas/v3.1.0.html#style-values
32+
case deepObject
2933
}
3034

3135
extension ParameterStyle {
@@ -53,6 +57,7 @@ extension URICoderConfiguration.Style {
5357
switch style {
5458
case .form: self = .form
5559
case .simple: self = .simple
60+
case .deepObject: self = .deepObject
5661
}
5762
}
5863
}

Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift

+2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ struct URICoderConfiguration {
2525

2626
/// A style for form-based URI expansion.
2727
case form
28+
/// A style for nested variable expansion
29+
case deepObject
2830
}
2931

3032
/// A character used to escape the space character.

Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift

+41-2
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ struct URIParser: Sendable {
3636
}
3737

3838
/// A typealias for the underlying raw string storage.
39-
private typealias Raw = String.SubSequence
39+
typealias Raw = String.SubSequence
4040

4141
/// A parser error.
42-
private enum ParsingError: Swift.Error {
42+
enum ParsingError: Swift.Error, Hashable {
4343

4444
/// A malformed key-value pair was detected.
4545
case malformedKeyValuePair(Raw)
46+
/// An invalid configuration was detected.
47+
case invalidConfiguration(String)
4648
}
4749

4850
// MARK: - Parser implementations
@@ -61,13 +63,18 @@ extension URIParser {
6163
switch configuration.style {
6264
case .form: return [:]
6365
case .simple: return ["": [""]]
66+
case .deepObject: return [:]
6467
}
6568
}
6669
switch (configuration.style, configuration.explode) {
6770
case (.form, true): return try parseExplodedFormRoot()
6871
case (.form, false): return try parseUnexplodedFormRoot()
6972
case (.simple, true): return try parseExplodedSimpleRoot()
7073
case (.simple, false): return try parseUnexplodedSimpleRoot()
74+
case (.deepObject, true): return try parseExplodedDeepObjectRoot()
75+
case (.deepObject, false):
76+
let reason = "Deep object style is only valid with explode set to true"
77+
throw ParsingError.invalidConfiguration(reason)
7178
}
7279
}
7380

@@ -205,6 +212,38 @@ extension URIParser {
205212
}
206213
}
207214
}
215+
/// Parses the root node assuming the raw string uses the deepObject style
216+
/// and the explode parameter is enabled.
217+
/// - Returns: The parsed root node.
218+
/// - Throws: An error if parsing fails.
219+
private mutating func parseExplodedDeepObjectRoot() throws -> URIParsedNode {
220+
let parseNode = try parseGenericRoot { data, appendPair in
221+
let keyValueSeparator: Character = "="
222+
let pairSeparator: Character = "&"
223+
let nestedKeyStartingCharacter: Character = "["
224+
let nestedKeyEndingCharacter: Character = "]"
225+
func nestedKey(from deepObjectKey: String.SubSequence) -> Raw {
226+
var unescapedDeepObjectKey = Substring(deepObjectKey.removingPercentEncoding ?? "")
227+
let topLevelKey = unescapedDeepObjectKey.parseUpToCharacterOrEnd(nestedKeyStartingCharacter)
228+
let nestedKey = unescapedDeepObjectKey.parseUpToCharacterOrEnd(nestedKeyEndingCharacter)
229+
return nestedKey.isEmpty ? topLevelKey : nestedKey
230+
}
231+
while !data.isEmpty {
232+
let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd(
233+
first: keyValueSeparator,
234+
second: pairSeparator
235+
)
236+
guard case .foundFirst = firstResult else { throw ParsingError.malformedKeyValuePair(firstValue) }
237+
// Hit the key/value separator, so a value will follow.
238+
let secondValue = data.parseUpToCharacterOrEnd(pairSeparator)
239+
let key = nestedKey(from: firstValue)
240+
let value = secondValue
241+
appendPair(key, [value])
242+
}
243+
}
244+
for (key, value) in parseNode where value.count > 1 { throw ParsingError.malformedKeyValuePair(key) }
245+
return parseNode
246+
}
208247
}
209248

210249
// MARK: - URIParser utilities

Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift

+23-3
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,16 @@ extension CharacterSet {
6565
extension URISerializer {
6666

6767
/// A serializer error.
68-
private enum SerializationError: Swift.Error {
68+
enum SerializationError: Swift.Error, Hashable {
6969

7070
/// Nested containers are not supported.
7171
case nestedContainersNotSupported
72+
/// Deep object arrays are not supported.
73+
case deepObjectsArrayNotSupported
74+
/// Deep object with primitive values are not supported.
75+
case deepObjectsWithPrimitiveValuesNotSupported
76+
/// An invalid configuration was detected.
77+
case invalidConfiguration(String)
7278
}
7379

7480
/// Computes an escaped version of the provided string.
@@ -117,6 +123,7 @@ extension URISerializer {
117123
switch configuration.style {
118124
case .form: keyAndValueSeparator = "="
119125
case .simple: keyAndValueSeparator = nil
126+
case .deepObject: throw SerializationError.deepObjectsWithPrimitiveValuesNotSupported
120127
}
121128
try serializePrimitiveKeyValuePair(primitive, forKey: key, separator: keyAndValueSeparator)
122129
case .array(let array): try serializeArray(array.map(unwrapPrimitiveValue), forKey: key)
@@ -180,6 +187,7 @@ extension URISerializer {
180187
case (.simple, _):
181188
keyAndValueSeparator = nil
182189
pairSeparator = ","
190+
case (.deepObject, _): throw SerializationError.deepObjectsArrayNotSupported
183191
}
184192
func serializeNext(_ element: URIEncodedNode.Primitive) throws {
185193
if let keyAndValueSeparator {
@@ -228,8 +236,18 @@ extension URISerializer {
228236
case (.simple, false):
229237
keyAndValueSeparator = ","
230238
pairSeparator = ","
239+
case (.deepObject, true):
240+
keyAndValueSeparator = "="
241+
pairSeparator = "&"
242+
case (.deepObject, false):
243+
let reason = "Deep object style is only valid with explode set to true"
244+
throw SerializationError.invalidConfiguration(reason)
231245
}
232246

247+
func serializeNestedKey(_ elementKey: String, forKey rootKey: String) -> String {
248+
guard case .deepObject = configuration.style else { return elementKey }
249+
return rootKey + "[" + elementKey + "]"
250+
}
233251
func serializeNext(_ element: URIEncodedNode.Primitive, forKey elementKey: String) throws {
234252
try serializePrimitiveKeyValuePair(element, forKey: elementKey, separator: keyAndValueSeparator)
235253
}
@@ -238,10 +256,12 @@ extension URISerializer {
238256
data.append(containerKeyAndValue)
239257
}
240258
for (elementKey, element) in sortedDictionary.dropLast() {
241-
try serializeNext(element, forKey: elementKey)
259+
try serializeNext(element, forKey: serializeNestedKey(elementKey, forKey: key))
242260
data.append(pairSeparator)
243261
}
244-
if let (elementKey, element) = sortedDictionary.last { try serializeNext(element, forKey: elementKey) }
262+
if let (elementKey, element) = sortedDictionary.last {
263+
try serializeNext(element, forKey: serializeNestedKey(elementKey, forKey: key))
264+
}
245265
}
246266
}
247267

Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift

+7
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,11 @@ final class Test_URIEncoder: Test_Runtime {
2323
let encodedString = try encoder.encode(Foo(bar: "hello world"), forKey: "root")
2424
XCTAssertEqual(encodedString, "bar=hello+world")
2525
}
26+
func testNestedEncoding() throws {
27+
struct Foo: Encodable { var bar: String }
28+
let serializer = URISerializer(configuration: .deepObjectExplode)
29+
let encoder = URIEncoder(serializer: serializer)
30+
let encodedString = try encoder.encode(Foo(bar: "hello world"), forKey: "root")
31+
XCTAssertEqual(encodedString, "root%5Bbar%5D=hello%20world")
32+
}
2633
}

Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift

+49-16
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ final class Test_URIParser: Test_Runtime {
1818

1919
let testedVariants: [URICoderConfiguration] = [
2020
.formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode,
21+
.deepObjectExplode,
2122
]
2223

2324
func testParsing() throws {
@@ -29,7 +30,8 @@ final class Test_URIParser: Test_Runtime {
2930
simpleExplode: .custom("", value: ["": [""]]),
3031
simpleUnexplode: .custom("", value: ["": [""]]),
3132
formDataExplode: "empty=",
32-
formDataUnexplode: "empty="
33+
formDataUnexplode: "empty=",
34+
deepObjectExplode: "object%5Bempty%5D="
3335
),
3436
value: ["empty": [""]]
3537
),
@@ -40,7 +42,8 @@ final class Test_URIParser: Test_Runtime {
4042
simpleExplode: .custom("", value: ["": [""]]),
4143
simpleUnexplode: .custom("", value: ["": [""]]),
4244
formDataExplode: "",
43-
formDataUnexplode: ""
45+
formDataUnexplode: "",
46+
deepObjectExplode: ""
4447
),
4548
value: [:]
4649
),
@@ -51,7 +54,8 @@ final class Test_URIParser: Test_Runtime {
5154
simpleExplode: .custom("fred", value: ["": ["fred"]]),
5255
simpleUnexplode: .custom("fred", value: ["": ["fred"]]),
5356
formDataExplode: "who=fred",
54-
formDataUnexplode: "who=fred"
57+
formDataUnexplode: "who=fred",
58+
deepObjectExplode: "object%5Bwho%5D=fred"
5559
),
5660
value: ["who": ["fred"]]
5761
),
@@ -62,7 +66,8 @@ final class Test_URIParser: Test_Runtime {
6266
simpleExplode: .custom("Hello%20World", value: ["": ["Hello World"]]),
6367
simpleUnexplode: .custom("Hello%20World", value: ["": ["Hello World"]]),
6468
formDataExplode: "hello=Hello+World",
65-
formDataUnexplode: "hello=Hello+World"
69+
formDataUnexplode: "hello=Hello+World",
70+
deepObjectExplode: "object%5Bhello%5D=Hello%20World"
6671
),
6772
value: ["hello": ["Hello World"]]
6873
),
@@ -73,7 +78,11 @@ final class Test_URIParser: Test_Runtime {
7378
simpleExplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]),
7479
simpleUnexplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]),
7580
formDataExplode: "list=red&list=green&list=blue",
76-
formDataUnexplode: "list=red,green,blue"
81+
formDataUnexplode: "list=red,green,blue",
82+
deepObjectExplode: .custom(
83+
"object%5Blist%5D=red&object%5Blist%5D=green&object%5Blist%5D=blue",
84+
expectedError: .malformedKeyValuePair("list")
85+
)
7786
),
7887
value: ["list": ["red", "green", "blue"]]
7988
),
@@ -93,22 +102,37 @@ final class Test_URIParser: Test_Runtime {
93102
formDataUnexplode: .custom(
94103
"keys=comma,%2C,dot,.,semi,%3B",
95104
value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]]
96-
)
105+
),
106+
deepObjectExplode: "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Bsemi%5D=%3B"
97107
),
98108
value: ["semi": [";"], "dot": ["."], "comma": [","]]
99109
),
100110
]
101111
for testCase in cases {
102112
func testVariant(_ variant: Case.Variant, _ input: Case.Variants.Input) throws {
103113
var parser = URIParser(configuration: variant.config, data: input.string[...])
104-
let parsedNode = try parser.parseRoot()
105-
XCTAssertEqual(
106-
parsedNode,
107-
input.valueOverride ?? testCase.value,
108-
"Failed for config: \(variant.name)",
109-
file: testCase.file,
110-
line: testCase.line
111-
)
114+
do {
115+
let parsedNode = try parser.parseRoot()
116+
XCTAssertEqual(
117+
parsedNode,
118+
input.valueOverride ?? testCase.value,
119+
"Failed for config: \(variant.name)",
120+
file: testCase.file,
121+
line: testCase.line
122+
)
123+
} catch {
124+
guard let expectedError = input.expectedError, let parsingError = error as? ParsingError else {
125+
XCTAssert(false, "Unexpected error thrown: \(error)", file: testCase.file, line: testCase.line)
126+
return
127+
}
128+
XCTAssertEqual(
129+
expectedError,
130+
parsingError,
131+
"Failed for config: \(variant.name)",
132+
file: testCase.file,
133+
line: testCase.line
134+
)
135+
}
112136
}
113137
let variants = testCase.variants
114138
try testVariant(.formExplode, variants.formExplode)
@@ -117,6 +141,7 @@ final class Test_URIParser: Test_Runtime {
117141
try testVariant(.simpleUnexplode, variants.simpleUnexplode)
118142
try testVariant(.formDataExplode, variants.formDataExplode)
119143
try testVariant(.formDataUnexplode, variants.formDataUnexplode)
144+
try testVariant(.deepObjectExplode, variants.deepObjectExplode)
120145
}
121146
}
122147
}
@@ -133,25 +158,32 @@ extension Test_URIParser {
133158
static let simpleUnexplode: Self = .init(name: "simpleUnexplode", config: .simpleUnexplode)
134159
static let formDataExplode: Self = .init(name: "formDataExplode", config: .formDataExplode)
135160
static let formDataUnexplode: Self = .init(name: "formDataUnexplode", config: .formDataUnexplode)
161+
static let deepObjectExplode: Self = .init(name: "deepObjectExplode", config: .deepObjectExplode)
136162
}
137163
struct Variants {
138164

139165
struct Input: ExpressibleByStringLiteral {
140166
var string: String
141167
var valueOverride: URIParsedNode?
168+
var expectedError: ParsingError?
142169

143-
init(string: String, valueOverride: URIParsedNode? = nil) {
170+
init(string: String, valueOverride: URIParsedNode? = nil, expectedError: ParsingError? = nil) {
144171
self.string = string
145172
self.valueOverride = valueOverride
173+
self.expectedError = expectedError
146174
}
147175

148176
static func custom(_ string: String, value: URIParsedNode) -> Self {
149-
.init(string: string, valueOverride: value)
177+
.init(string: string, valueOverride: value, expectedError: nil)
178+
}
179+
static func custom(_ string: String, expectedError: ParsingError) -> Self {
180+
.init(string: string, valueOverride: nil, expectedError: expectedError)
150181
}
151182

152183
init(stringLiteral value: String) {
153184
self.string = value
154185
self.valueOverride = nil
186+
self.expectedError = nil
155187
}
156188
}
157189

@@ -161,6 +193,7 @@ extension Test_URIParser {
161193
var simpleUnexplode: Input
162194
var formDataExplode: Input
163195
var formDataUnexplode: Input
196+
var deepObjectExplode: Input
164197
}
165198
var variants: Variants
166199
var value: URIParsedNode

0 commit comments

Comments
 (0)