Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Runtime] Add support of deepObject style in query params #100

Merged
merged 7 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 + "]"
czechboy0 marked this conversation as resolved.
Show resolved Hide resolved
}
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