Skip to content

Commit c2ab707

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 c2ab707

File tree

10 files changed

+218
-44
lines changed

10 files changed

+218
-44
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

+92-7
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,45 @@ 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 unescapedFirstValue = Substring(deepObjectKey.removingPercentEncoding ?? "")
230+
let nestedKey = unescapedFirstValue.parseUpToCharacterOrEnd(
231+
startingCharacter: nestedKeyStartingCharacter,
232+
nestedKeyEndingCharacter
233+
)
234+
return nestedKey
235+
}
236+
237+
while !data.isEmpty {
238+
let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd(
239+
first: keyValueSeparator,
240+
second: pairSeparator
241+
)
242+
243+
guard case .foundFirst = firstResult else {
244+
throw ParsingError.malformedKeyValuePair(firstValue)
245+
}
246+
// Hit the key/value separator, so a value will follow.
247+
let secondValue = data.parseUpToCharacterOrEnd(pairSeparator)
248+
let key = nestedKey(from: firstValue)
249+
let value = secondValue
250+
251+
appendPair(key, [value])
252+
}
253+
}
254+
}
208255
}
209256

210257
// MARK: - URIParser utilities
@@ -302,17 +349,55 @@ extension String.SubSequence {
302349
return finalize(.foundSecondOrEnd)
303350
}
304351

352+
305353
/// Accumulates characters until the provided character is found,
306354
/// or the end is reached. Moves the underlying startIndex.
307-
/// - Parameter character: A character to stop at.
355+
/// - Parameters:
356+
/// - startingCharacter: A character to start with.
357+
/// - endingCharacter: A character to stop at.
358+
/// If not provided or not found then uses the current start index as a starting character.
308359
/// - Returns: The accumulated substring.
309-
fileprivate mutating func parseUpToCharacterOrEnd(_ character: Character) -> Self {
310-
let startIndex = startIndex
360+
fileprivate mutating func parseUpToCharacterOrEnd(startingCharacter: Character? = nil, _ endingCharacter: Character) -> Self {
311361
guard startIndex != endIndex else { return .init() }
312-
var currentIndex = startIndex
313-
362+
363+
let startingCharacterIndex: Substring.Index = {
364+
guard let startingCharacter,
365+
let index = firstIndex(of: startingCharacter) else {
366+
return startIndex
367+
}
368+
return
369+
}()
370+
var currentIndex = startingCharacterIndex
371+
314372
func finalize() -> Self {
315-
let parsed = self[startIndex..<currentIndex]
373+
let parsed = self[startingCharacterIndex..<currentIndex]
374+
guard currentIndex == endIndex else {
375+
self = self[index(after: currentIndex)...]
376+
return parsed
377+
}
378+
self = .init()
379+
return parsed
380+
}
381+
while currentIndex != endIndex {
382+
let currentChar = self[currentIndex]
383+
if currentChar == endingCharacter { return finalize() } else { formIndex(after: &currentIndex) }
384+
}
385+
return finalize()
386+
}
387+
388+
389+
/// Accumulates characters from the `startingCharacter` character provided,
390+
/// until the `endingCharacter` is reached. Moves the underlying startIndex.
391+
/// - Parameters:
392+
/// - startingCharacter: A character to start with.
393+
/// - endingCharacter: A character to stop at.
394+
/// - Returns: The accumulated substring.
395+
fileprivate mutating func parseBetweenCharacters(startingCharacter: Character, endingCharacter: Character) -> Self {
396+
guard let startingCharacterIndex = firstIndex(of: startingCharacter) else { return self }
397+
var currentIndex = startingCharacterIndex
398+
399+
func finalize() -> Self {
400+
let parsed = self[index(after: startingCharacterIndex)..<currentIndex]
316401
guard currentIndex == endIndex else {
317402
self = self[index(after: currentIndex)...]
318403
return parsed
@@ -322,7 +407,7 @@ extension String.SubSequence {
322407
}
323408
while currentIndex != endIndex {
324409
let currentChar = self[currentIndex]
325-
if currentChar == character { return finalize() } else { formIndex(after: &currentIndex) }
410+
if currentChar == endingCharacter { return finalize() } else { formIndex(after: &currentIndex) }
326411
}
327412
return finalize()
328413
}

Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift

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

7070
/// Nested containers are not supported.
7171
case nestedContainersNotSupported
72+
73+
/// An invalid configuration was detected.
74+
case invalidConfiguration(String)
7275
}
7376

7477
/// Computes an escaped version of the provided string.
@@ -117,6 +120,7 @@ extension URISerializer {
117120
switch configuration.style {
118121
case .form: keyAndValueSeparator = "="
119122
case .simple: keyAndValueSeparator = nil
123+
case .deepObject: keyAndValueSeparator = "="
120124
}
121125
try serializePrimitiveKeyValuePair(primitive, forKey: key, separator: keyAndValueSeparator)
122126
case .array(let array): try serializeArray(array.map(unwrapPrimitiveValue), forKey: key)
@@ -180,6 +184,9 @@ extension URISerializer {
180184
case (.simple, _):
181185
keyAndValueSeparator = nil
182186
pairSeparator = ","
187+
case (.deepObject, _):
188+
keyAndValueSeparator = "="
189+
pairSeparator = "&"
183190
}
184191
func serializeNext(_ element: URIEncodedNode.Primitive) throws {
185192
if let keyAndValueSeparator {
@@ -228,8 +235,18 @@ extension URISerializer {
228235
case (.simple, false):
229236
keyAndValueSeparator = ","
230237
pairSeparator = ","
238+
case (.deepObject, true):
239+
keyAndValueSeparator = "="
240+
pairSeparator = "&"
241+
case (.deepObject, false):
242+
let reason = "Deep object style is only valid with explode set to true"
243+
throw SerializationError.invalidConfiguration(reason)
231244
}
232245

246+
func serializeNestedKey(_ elementKey: String, forKey rootKey: String) -> String {
247+
guard case .deepObject = configuration.style else { return elementKey }
248+
return rootKey + "[" + elementKey + "]"
249+
}
233250
func serializeNext(_ element: URIEncodedNode.Primitive, forKey elementKey: String) throws {
234251
try serializePrimitiveKeyValuePair(element, forKey: elementKey, separator: keyAndValueSeparator)
235252
}
@@ -238,10 +255,13 @@ extension URISerializer {
238255
data.append(containerKeyAndValue)
239256
}
240257
for (elementKey, element) in sortedDictionary.dropLast() {
241-
try serializeNext(element, forKey: elementKey)
258+
try serializeNext(element, forKey: serializeNestedKey(elementKey, forKey: key))
242259
data.append(pairSeparator)
243260
}
244-
if let (elementKey, element) = sortedDictionary.last { try serializeNext(element, forKey: elementKey) }
261+
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

+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

0 commit comments

Comments
 (0)