Skip to content

Commit

Permalink
[Runtime] Add support of deepObject style in query params (#100)
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.

---------

Co-authored-by: Honza Dvorsky <honza@apple.com>
  • Loading branch information
kstefanou52 and czechboy0 authored Apr 16, 2024
1 parent 9d4a2ff commit 9a8291f
Show file tree
Hide file tree
Showing 10 changed files with 300 additions and 73 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
5 changes: 5 additions & 0 deletions Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -53,6 +57,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,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.
Expand Down
43 changes: 41 additions & 2 deletions Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -61,13 +63,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 +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
Expand Down
26 changes: 23 additions & 3 deletions Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
65 changes: 49 additions & 16 deletions Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ final class Test_URIParser: Test_Runtime {

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

func testParsing() throws {
Expand All @@ -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": [""]]
),
Expand All @@ -40,7 +42,8 @@ final class Test_URIParser: Test_Runtime {
simpleExplode: .custom("", value: ["": [""]]),
simpleUnexplode: .custom("", value: ["": [""]]),
formDataExplode: "",
formDataUnexplode: ""
formDataUnexplode: "",
deepObjectExplode: ""
),
value: [:]
),
Expand All @@ -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"]]
),
Expand All @@ -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"]]
),
Expand All @@ -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"]]
),
Expand All @@ -93,22 +102,37 @@ 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": [","]]
),
]
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)
Expand All @@ -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)
}
}
}
Expand All @@ -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
}
}

Expand All @@ -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
Expand Down
Loading

0 comments on commit 9a8291f

Please sign in to comment.