Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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
54 changes: 52 additions & 2 deletions Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,16 @@ 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, Equatable {

/// 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,48 @@ 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])
}
}

try parseNode.forEach { (key, value) in
if value.count > 1 { throw ParsingError.malformedKeyValuePair(key) }
}
return parseNode
}
}

// MARK: - URIParser utilities
Expand Down
28 changes: 25 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, Equatable {

/// Nested containers are not supported.
case nestedContainersNotSupported

/// Deep object arrays are not supported.
case deepObjectsArrayNotSupported

/// 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: 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 +187,8 @@ 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 +237,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 +257,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")
}
}
65 changes: 48 additions & 17 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: "object%5Bempty%5D="
),
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: "object%5Bwho%5D=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: "object%5Bhello%5D=Hello%20World"
),
value: ["hello": ["Hello World"]]
),
Expand All @@ -73,7 +77,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 +101,35 @@ 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 serializationError = error as? ParsingError else { throw error }
XCTAssertEqual(
expectedError,
serializationError,
"Failed for config: \(variant.name)",
file: testCase.file,
line: testCase.line
)
}
}
let variants = testCase.variants
try testVariant(.formExplode, variants.formExplode)
Expand All @@ -117,6 +138,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 +155,33 @@ 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 +191,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