Skip to content

Commit

Permalink
[Runtime] Add support of deepObject style in query params
Browse files Browse the repository at this point in the history
### 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.
  • Loading branch information
kstefanou52 committed Apr 4, 2024
1 parent 76951d7 commit c2ab707
Show file tree
Hide file tree
Showing 10 changed files with 218 additions and 44 deletions.
4 changes: 3 additions & 1 deletion Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
///
/// 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 {
Expand Down Expand Up @@ -53,6 +58,7 @@ extension URICoderConfiguration.Style {
switch style {
case .form: self = .form
case .simple: self = .simple
case .deepObject: self = .deepObject
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ 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.
Expand Down
99 changes: 92 additions & 7 deletions Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ private enum ParsingError: Swift.Error {

/// A malformed key-value pair was detected.
case malformedKeyValuePair(Raw)

/// An invalid configuration was detected.
case invalidConfiguration(String)
}

// MARK: - Parser implementations
Expand All @@ -61,13 +64,18 @@ extension URIParser {
switch configuration.style {
case .form: return [:]
case .simple: return ["": [""]]
case .deepObject: return [:]
}
}
switch (configuration.style, configuration.explode) {
case (.form, true): return try parseExplodedFormRoot()
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)
}
}

Expand Down Expand Up @@ -205,6 +213,45 @@ 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 {
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 unescapedFirstValue = Substring(deepObjectKey.removingPercentEncoding ?? "")
let nestedKey = unescapedFirstValue.parseUpToCharacterOrEnd(
startingCharacter: nestedKeyStartingCharacter,
nestedKeyEndingCharacter
)
return 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])
}
}
}
}

// MARK: - URIParser utilities
Expand Down Expand Up @@ -302,17 +349,55 @@ extension String.SubSequence {
return finalize(.foundSecondOrEnd)
}


/// Accumulates characters until the provided character is found,
/// or the end is reached. Moves the underlying startIndex.
/// - Parameter character: A character to stop at.
/// - Parameters:
/// - startingCharacter: A character to start with.
/// - endingCharacter: A character to stop at.
/// If not provided or not found then uses the current start index as a starting character.
/// - Returns: The accumulated substring.
fileprivate mutating func parseUpToCharacterOrEnd(_ character: Character) -> Self {
let startIndex = startIndex
fileprivate mutating func parseUpToCharacterOrEnd(startingCharacter: Character? = nil, _ endingCharacter: Character) -> Self {
guard startIndex != endIndex else { return .init() }
var currentIndex = startIndex


let startingCharacterIndex: Substring.Index = {
guard let startingCharacter,
let index = firstIndex(of: startingCharacter) else {
return startIndex
}
return
}()
var currentIndex = startingCharacterIndex

func finalize() -> Self {
let parsed = self[startIndex..<currentIndex]
let parsed = self[startingCharacterIndex..<currentIndex]
guard currentIndex == endIndex else {
self = self[index(after: currentIndex)...]
return parsed
}
self = .init()
return parsed
}
while currentIndex != endIndex {
let currentChar = self[currentIndex]
if currentChar == endingCharacter { return finalize() } else { formIndex(after: &currentIndex) }
}
return finalize()
}


/// Accumulates characters from the `startingCharacter` character provided,
/// until the `endingCharacter` is reached. Moves the underlying startIndex.
/// - Parameters:
/// - startingCharacter: A character to start with.
/// - endingCharacter: A character to stop at.
/// - Returns: The accumulated substring.
fileprivate mutating func parseBetweenCharacters(startingCharacter: Character, endingCharacter: Character) -> Self {
guard let startingCharacterIndex = firstIndex(of: startingCharacter) else { return self }
var currentIndex = startingCharacterIndex

func finalize() -> Self {
let parsed = self[index(after: startingCharacterIndex)..<currentIndex]
guard currentIndex == endIndex else {
self = self[index(after: currentIndex)...]
return parsed
Expand All @@ -322,7 +407,7 @@ extension String.SubSequence {
}
while currentIndex != endIndex {
let currentChar = self[currentIndex]
if currentChar == character { return finalize() } else { formIndex(after: &currentIndex) }
if currentChar == endingCharacter { return finalize() } else { formIndex(after: &currentIndex) }
}
return finalize()
}
Expand Down
24 changes: 22 additions & 2 deletions Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ extension URISerializer {

/// Nested containers are not supported.
case nestedContainersNotSupported

/// An invalid configuration was detected.
case invalidConfiguration(String)
}

/// Computes an escaped version of the provided string.
Expand Down Expand Up @@ -117,6 +120,7 @@ extension URISerializer {
switch configuration.style {
case .form: keyAndValueSeparator = "="
case .simple: keyAndValueSeparator = nil
case .deepObject: keyAndValueSeparator = "="
}
try serializePrimitiveKeyValuePair(primitive, forKey: key, separator: keyAndValueSeparator)
case .array(let array): try serializeArray(array.map(unwrapPrimitiveValue), forKey: key)
Expand Down Expand Up @@ -180,6 +184,9 @@ extension URISerializer {
case (.simple, _):
keyAndValueSeparator = nil
pairSeparator = ","
case (.deepObject, _):
keyAndValueSeparator = "="
pairSeparator = "&"
}
func serializeNext(_ element: URIEncodedNode.Primitive) throws {
if let keyAndValueSeparator {
Expand Down Expand Up @@ -228,8 +235,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)
}
Expand All @@ -238,10 +255,13 @@ 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))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,12 @@ 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")
}
}
22 changes: 15 additions & 7 deletions Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import XCTest
final class Test_URIParser: Test_Runtime {

let testedVariants: [URICoderConfiguration] = [
.formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode,
.formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode, .deepObjectExplode
]

func testParsing() throws {
Expand All @@ -29,7 +29,8 @@ final class Test_URIParser: Test_Runtime {
simpleExplode: .custom("", value: ["": [""]]),
simpleUnexplode: .custom("", value: ["": [""]]),
formDataExplode: "empty=",
formDataUnexplode: "empty="
formDataUnexplode: "empty=",
deepObjectExplode: "empty="
),
value: ["empty": [""]]
),
Expand All @@ -40,7 +41,8 @@ final class Test_URIParser: Test_Runtime {
simpleExplode: .custom("", value: ["": [""]]),
simpleUnexplode: .custom("", value: ["": [""]]),
formDataExplode: "",
formDataUnexplode: ""
formDataUnexplode: "",
deepObjectExplode: ""
),
value: [:]
),
Expand All @@ -51,7 +53,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: "who=fred"
),
value: ["who": ["fred"]]
),
Expand All @@ -62,7 +65,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: "hello=Hello%20World"
),
value: ["hello": ["Hello World"]]
),
Expand All @@ -73,7 +77,8 @@ 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: "list=red&list=green&list=blue"
),
value: ["list": ["red", "green", "blue"]]
),
Expand All @@ -93,7 +98,8 @@ final class Test_URIParser: Test_Runtime {
formDataUnexplode: .custom(
"keys=comma,%2C,dot,.,semi,%3B",
value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]]
)
),
deepObjectExplode: "comma=%2C&dot=.&semi=%3B"
),
value: ["semi": [";"], "dot": ["."], "comma": [","]]
),
Expand Down Expand Up @@ -133,6 +139,7 @@ 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 {

Expand Down Expand Up @@ -161,6 +168,7 @@ extension Test_URIParser {
var simpleUnexplode: Input
var formDataExplode: Input
var formDataUnexplode: Input
var deepObjectExplode: Input
}
var variants: Variants
var value: URIParsedNode
Expand Down
Loading

0 comments on commit c2ab707

Please sign in to comment.