Skip to content

Commit 3e3b4e6

Browse files
committed
[Runtime] Add support of deepObject style in query params
### 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.
1 parent 76951d7 commit 3e3b4e6

File tree

10 files changed

+178
-37
lines changed

10 files changed

+178
-37
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

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

3136
extension ParameterStyle {
@@ -53,6 +58,7 @@ extension URICoderConfiguration.Style {
5358
switch style {
5459
case .form: self = .form
5560
case .simple: self = .simple
61+
case .deepObject: self = .deepObject
5662
}
5763
}
5864
}

Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift

+3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ struct URICoderConfiguration {
2525

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

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

Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift

+45
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ private enum ParsingError: Swift.Error {
4343

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

4851
// MARK: - Parser implementations
@@ -61,13 +64,18 @@ extension URIParser {
6164
switch configuration.style {
6265
case .form: return [:]
6366
case .simple: return ["": [""]]
67+
case .deepObject: return [:]
6468
}
6569
}
6670
switch (configuration.style, configuration.explode) {
6771
case (.form, true): return try parseExplodedFormRoot()
6872
case (.form, false): return try parseUnexplodedFormRoot()
6973
case (.simple, true): return try parseExplodedSimpleRoot()
7074
case (.simple, false): return try parseUnexplodedSimpleRoot()
75+
case (.deepObject, true): return try parseExplodedDeepObjectRoot()
76+
case (.deepObject, false):
77+
let reason = "Deep object style is only valid with explode set to true"
78+
throw ParsingError.invalidConfiguration(reason)
7179
}
7280
}
7381

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

210255
// MARK: - URIParser utilities

Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift

+24-2
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ extension URISerializer {
6969

7070
/// Nested containers are not supported.
7171
case nestedContainersNotSupported
72+
73+
/// Deep object arrays are not supported.
74+
case deepObjectsArrayNotSupported
75+
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: keyAndValueSeparator = "="
120127
}
121128
try serializePrimitiveKeyValuePair(primitive, forKey: key, separator: keyAndValueSeparator)
122129
case .array(let array): try serializeArray(array.map(unwrapPrimitiveValue), forKey: key)
@@ -180,6 +187,8 @@ extension URISerializer {
180187
case (.simple, _):
181188
keyAndValueSeparator = nil
182189
pairSeparator = ","
190+
case (.deepObject, _):
191+
throw SerializationError.deepObjectsArrayNotSupported
183192
}
184193
func serializeNext(_ element: URIEncodedNode.Primitive) throws {
185194
if let keyAndValueSeparator {
@@ -228,8 +237,18 @@ extension URISerializer {
228237
case (.simple, false):
229238
keyAndValueSeparator = ","
230239
pairSeparator = ","
240+
case (.deepObject, true):
241+
keyAndValueSeparator = "="
242+
pairSeparator = "&"
243+
case (.deepObject, false):
244+
let reason = "Deep object style is only valid with explode set to true"
245+
throw SerializationError.invalidConfiguration(reason)
231246
}
232247

248+
func serializeNestedKey(_ elementKey: String, forKey rootKey: String) -> String {
249+
guard case .deepObject = configuration.style else { return elementKey }
250+
return rootKey + "[" + elementKey + "]"
251+
}
233252
func serializeNext(_ element: URIEncodedNode.Primitive, forKey elementKey: String) throws {
234253
try serializePrimitiveKeyValuePair(element, forKey: elementKey, separator: keyAndValueSeparator)
235254
}
@@ -238,10 +257,13 @@ extension URISerializer {
238257
data.append(containerKeyAndValue)
239258
}
240259
for (elementKey, element) in sortedDictionary.dropLast() {
241-
try serializeNext(element, forKey: elementKey)
260+
try serializeNext(element, forKey: serializeNestedKey(elementKey, forKey: key))
242261
data.append(pairSeparator)
243262
}
244-
if let (elementKey, element) = sortedDictionary.last { try serializeNext(element, forKey: elementKey) }
263+
264+
if let (elementKey, element) = sortedDictionary.last {
265+
try serializeNext(element, forKey: serializeNestedKey(elementKey, forKey: key))
266+
}
245267
}
246268
}
247269

Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift

+8
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,12 @@ 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+
27+
func testNestedEncoding() throws {
28+
struct Foo: Encodable { var bar: String }
29+
let serializer = URISerializer(configuration: .deepObjectExplode)
30+
let encoder = URIEncoder(serializer: serializer)
31+
let encodedString = try encoder.encode(Foo(bar: "hello world"), forKey: "root")
32+
XCTAssertEqual(encodedString, "root%5Bbar%5D=hello%20world")
33+
}
2634
}

Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift

+15-7
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import XCTest
1717
final class Test_URIParser: Test_Runtime {
1818

1919
let testedVariants: [URICoderConfiguration] = [
20-
.formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode,
20+
.formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode, .deepObjectExplode
2121
]
2222

2323
func testParsing() throws {
@@ -29,7 +29,8 @@ final class Test_URIParser: Test_Runtime {
2929
simpleExplode: .custom("", value: ["": [""]]),
3030
simpleUnexplode: .custom("", value: ["": [""]]),
3131
formDataExplode: "empty=",
32-
formDataUnexplode: "empty="
32+
formDataUnexplode: "empty=",
33+
deepObjectExplode: "empty="
3334
),
3435
value: ["empty": [""]]
3536
),
@@ -40,7 +41,8 @@ final class Test_URIParser: Test_Runtime {
4041
simpleExplode: .custom("", value: ["": [""]]),
4142
simpleUnexplode: .custom("", value: ["": [""]]),
4243
formDataExplode: "",
43-
formDataUnexplode: ""
44+
formDataUnexplode: "",
45+
deepObjectExplode: ""
4446
),
4547
value: [:]
4648
),
@@ -51,7 +53,8 @@ final class Test_URIParser: Test_Runtime {
5153
simpleExplode: .custom("fred", value: ["": ["fred"]]),
5254
simpleUnexplode: .custom("fred", value: ["": ["fred"]]),
5355
formDataExplode: "who=fred",
54-
formDataUnexplode: "who=fred"
56+
formDataUnexplode: "who=fred",
57+
deepObjectExplode: "who=fred"
5558
),
5659
value: ["who": ["fred"]]
5760
),
@@ -62,7 +65,8 @@ final class Test_URIParser: Test_Runtime {
6265
simpleExplode: .custom("Hello%20World", value: ["": ["Hello World"]]),
6366
simpleUnexplode: .custom("Hello%20World", value: ["": ["Hello World"]]),
6467
formDataExplode: "hello=Hello+World",
65-
formDataUnexplode: "hello=Hello+World"
68+
formDataUnexplode: "hello=Hello+World",
69+
deepObjectExplode: "hello=Hello%20World"
6670
),
6771
value: ["hello": ["Hello World"]]
6872
),
@@ -73,7 +77,8 @@ final class Test_URIParser: Test_Runtime {
7377
simpleExplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]),
7478
simpleUnexplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]),
7579
formDataExplode: "list=red&list=green&list=blue",
76-
formDataUnexplode: "list=red,green,blue"
80+
formDataUnexplode: "list=red,green,blue",
81+
deepObjectExplode: "list=red&list=green&list=blue"
7782
),
7883
value: ["list": ["red", "green", "blue"]]
7984
),
@@ -93,7 +98,8 @@ final class Test_URIParser: Test_Runtime {
9398
formDataUnexplode: .custom(
9499
"keys=comma,%2C,dot,.,semi,%3B",
95100
value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]]
96-
)
101+
),
102+
deepObjectExplode: "comma=%2C&dot=.&semi=%3B"
97103
),
98104
value: ["semi": [";"], "dot": ["."], "comma": [","]]
99105
),
@@ -133,6 +139,7 @@ extension Test_URIParser {
133139
static let simpleUnexplode: Self = .init(name: "simpleUnexplode", config: .simpleUnexplode)
134140
static let formDataExplode: Self = .init(name: "formDataExplode", config: .formDataExplode)
135141
static let formDataUnexplode: Self = .init(name: "formDataUnexplode", config: .formDataUnexplode)
142+
static let deepObjectExplode: Self = .init(name: "deepObjectExplode", config: .deepObjectExplode)
136143
}
137144
struct Variants {
138145

@@ -161,6 +168,7 @@ extension Test_URIParser {
161168
var simpleUnexplode: Input
162169
var formDataExplode: Input
163170
var formDataUnexplode: Input
171+
var deepObjectExplode: Input
164172
}
165173
var variants: Variants
166174
var value: URIParsedNode

Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift

+21-8
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ final class Test_URISerializer: Test_Runtime {
3131
simpleExplode: "",
3232
simpleUnexplode: "",
3333
formDataExplode: "empty=",
34-
formDataUnexplode: "empty="
34+
formDataUnexplode: "empty=",
35+
deepObjectExplode: "empty="
3536
)
3637
),
3738
makeCase(
@@ -43,7 +44,8 @@ final class Test_URISerializer: Test_Runtime {
4344
simpleExplode: "fred",
4445
simpleUnexplode: "fred",
4546
formDataExplode: "who=fred",
46-
formDataUnexplode: "who=fred"
47+
formDataUnexplode: "who=fred",
48+
deepObjectExplode: "who=fred"
4749
)
4850
),
4951
makeCase(
@@ -55,7 +57,8 @@ final class Test_URISerializer: Test_Runtime {
5557
simpleExplode: "1234",
5658
simpleUnexplode: "1234",
5759
formDataExplode: "x=1234",
58-
formDataUnexplode: "x=1234"
60+
formDataUnexplode: "x=1234",
61+
deepObjectExplode: "x=1234"
5962
)
6063
),
6164
makeCase(
@@ -67,7 +70,8 @@ final class Test_URISerializer: Test_Runtime {
6770
simpleExplode: "12.34",
6871
simpleUnexplode: "12.34",
6972
formDataExplode: "x=12.34",
70-
formDataUnexplode: "x=12.34"
73+
formDataUnexplode: "x=12.34",
74+
deepObjectExplode: "x=12.34"
7175
)
7276
),
7377
makeCase(
@@ -79,7 +83,8 @@ final class Test_URISerializer: Test_Runtime {
7983
simpleExplode: "true",
8084
simpleUnexplode: "true",
8185
formDataExplode: "enabled=true",
82-
formDataUnexplode: "enabled=true"
86+
formDataUnexplode: "enabled=true",
87+
deepObjectExplode: "enabled=true"
8388
)
8489
),
8590
makeCase(
@@ -91,7 +96,8 @@ final class Test_URISerializer: Test_Runtime {
9196
simpleExplode: "Hello%20World",
9297
simpleUnexplode: "Hello%20World",
9398
formDataExplode: "hello=Hello+World",
94-
formDataUnexplode: "hello=Hello+World"
99+
formDataUnexplode: "hello=Hello+World",
100+
deepObjectExplode: "hello=Hello%20World"
95101
)
96102
),
97103
makeCase(
@@ -103,7 +109,8 @@ final class Test_URISerializer: Test_Runtime {
103109
simpleExplode: "red,green,blue",
104110
simpleUnexplode: "red,green,blue",
105111
formDataExplode: "list=red&list=green&list=blue",
106-
formDataUnexplode: "list=red,green,blue"
112+
formDataUnexplode: "list=red,green,blue",
113+
deepObjectExplode: nil
107114
)
108115
),
109116
makeCase(
@@ -118,7 +125,8 @@ final class Test_URISerializer: Test_Runtime {
118125
simpleExplode: "comma=%2C,dot=.,semi=%3B",
119126
simpleUnexplode: "comma,%2C,dot,.,semi,%3B",
120127
formDataExplode: "comma=%2C&dot=.&semi=%3B",
121-
formDataUnexplode: "keys=comma,%2C,dot,.,semi,%3B"
128+
formDataUnexplode: "keys=comma,%2C,dot,.,semi,%3B",
129+
deepObjectExplode: "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Bsemi%5D=%3B"
122130
)
123131
),
124132
]
@@ -140,6 +148,9 @@ final class Test_URISerializer: Test_Runtime {
140148
try testVariant(.simpleUnexplode, testCase.variants.simpleUnexplode)
141149
try testVariant(.formDataExplode, testCase.variants.formDataExplode)
142150
try testVariant(.formDataUnexplode, testCase.variants.formDataUnexplode)
151+
if let deepObjectExplode = testCase.variants.deepObjectExplode {
152+
try testVariant(.deepObjectExplode, deepObjectExplode)
153+
}
143154
}
144155
}
145156
}
@@ -156,6 +167,7 @@ extension Test_URISerializer {
156167
static let simpleUnexplode: Self = .init(name: "simpleUnexplode", config: .simpleUnexplode)
157168
static let formDataExplode: Self = .init(name: "formDataExplode", config: .formDataExplode)
158169
static let formDataUnexplode: Self = .init(name: "formDataUnexplode", config: .formDataUnexplode)
170+
static let deepObjectExplode: Self = .init(name: "deepObjectExplode", config: .deepObjectExplode)
159171
}
160172
struct Variants {
161173
var formExplode: String
@@ -164,6 +176,7 @@ extension Test_URISerializer {
164176
var simpleUnexplode: String
165177
var formDataExplode: String
166178
var formDataUnexplode: String
179+
var deepObjectExplode: String?
167180
}
168181
var value: URIEncodedNode
169182
var key: String

0 commit comments

Comments
 (0)