Skip to content

Commit da2e5b8

Browse files
authored
Support nested arrays of primitive values inside of objects (#120)
### Motivation It's a useful pattern to define a single JSON schema for all your (e.g. query) parameters, and handle them as a single object in your code. In OpenAPI, that'd be expressed like this, for example: ```yaml # parameter name: myParams in: query explode: true style: form schema: $ref: '#/components/schemas/QueryObject' # schema QueryObject: type: object properties: myString: type: string myList: type: array items: type: string ``` Until now, the `myList` property would not be allowed, and would fail to serialize and parse, as arrays within objects were not allowed for `form` style parameters (used by query items, by default). ### Modifications This PR extends the support of the `form` style to handle single nesting in the top level objects. It does _not_ add support for arbitrarily deep nesting. As part of this work, we also now allow the `deepObject` style to do the same - use arrays nested in an object. ### Result The useful pattern of having an array within a "params" object works correctly now. ### Test Plan Added unit tests for all 4 components: encoder, decoder, serializer, and parser.
1 parent 82b474f commit da2e5b8

File tree

7 files changed

+111
-31
lines changed

7 files changed

+111
-31
lines changed

Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ enum URIEncodedNode: Equatable {
4747
/// A date value.
4848
case date(Date)
4949
}
50+
51+
/// A primitive value or an array of primitive values.
52+
enum PrimitiveOrArrayOfPrimitives: Equatable {
53+
54+
/// A primitive value.
55+
case primitive(Primitive)
56+
57+
/// An array of primitive values.
58+
case arrayOfPrimitives([Primitive])
59+
}
5060
}
5161

5262
extension URIEncodedNode {

Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,6 @@ extension URIParser {
241241
appendPair(key, [value])
242242
}
243243
}
244-
for (key, value) in parseNode where value.count > 1 { throw ParsingError.malformedKeyValuePair(key) }
245244
return parseNode
246245
}
247246
}

Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,7 @@ extension CharacterSet {
6565
extension URISerializer {
6666

6767
/// A serializer error.
68-
enum SerializationError: Swift.Error, Hashable {
69-
68+
enum SerializationError: Swift.Error, Hashable, CustomStringConvertible, LocalizedError {
7069
/// Nested containers are not supported.
7170
case nestedContainersNotSupported
7271
/// Deep object arrays are not supported.
@@ -75,6 +74,28 @@ extension URISerializer {
7574
case deepObjectsWithPrimitiveValuesNotSupported
7675
/// An invalid configuration was detected.
7776
case invalidConfiguration(String)
77+
78+
/// A human-readable description of the serialization error.
79+
///
80+
/// This computed property returns a string that includes information about the serialization error.
81+
///
82+
/// - Returns: A string describing the serialization error and its associated details.
83+
var description: String {
84+
switch self {
85+
case .nestedContainersNotSupported: "URISerializer: Nested containers are not supported"
86+
case .deepObjectsArrayNotSupported: "URISerializer: Deep object arrays are not supported"
87+
case .deepObjectsWithPrimitiveValuesNotSupported:
88+
"URISerializer: Deep object with primitive values are not supported"
89+
case .invalidConfiguration(let string): "URISerializer: Invalid configuration: \(string)"
90+
}
91+
}
92+
93+
/// A localized description of the serialization error.
94+
///
95+
/// This computed property provides a localized human-readable description of the serialization error, which is suitable for displaying to users.
96+
///
97+
/// - Returns: A localized string describing the serialization error.
98+
var errorDescription: String? { description }
7899
}
79100

80101
/// Computes an escaped version of the provided string.
@@ -114,6 +135,16 @@ extension URISerializer {
114135
guard case let .primitive(primitive) = node else { throw SerializationError.nestedContainersNotSupported }
115136
return primitive
116137
}
138+
func unwrapPrimitiveOrArrayOfPrimitives(_ node: URIEncodedNode) throws
139+
-> URIEncodedNode.PrimitiveOrArrayOfPrimitives
140+
{
141+
if case let .primitive(primitive) = node { return .primitive(primitive) }
142+
if case let .array(array) = node {
143+
let primitives = try array.map(unwrapPrimitiveValue)
144+
return .arrayOfPrimitives(primitives)
145+
}
146+
throw SerializationError.nestedContainersNotSupported
147+
}
117148
switch value {
118149
case .unset:
119150
// Nothing to serialize.
@@ -128,7 +159,7 @@ extension URISerializer {
128159
try serializePrimitiveKeyValuePair(primitive, forKey: key, separator: keyAndValueSeparator)
129160
case .array(let array): try serializeArray(array.map(unwrapPrimitiveValue), forKey: key)
130161
case .dictionary(let dictionary):
131-
try serializeDictionary(dictionary.mapValues(unwrapPrimitiveValue), forKey: key)
162+
try serializeDictionary(dictionary.mapValues(unwrapPrimitiveOrArrayOfPrimitives), forKey: key)
132163
}
133164
}
134165

@@ -213,9 +244,10 @@ extension URISerializer {
213244
/// - key: The key to serialize the value under (details depend on the
214245
/// style and explode parameters in the configuration).
215246
/// - Throws: An error if serialization of the dictionary fails.
216-
private mutating func serializeDictionary(_ dictionary: [String: URIEncodedNode.Primitive], forKey key: String)
217-
throws
218-
{
247+
private mutating func serializeDictionary(
248+
_ dictionary: [String: URIEncodedNode.PrimitiveOrArrayOfPrimitives],
249+
forKey key: String
250+
) throws {
219251
guard !dictionary.isEmpty else { return }
220252
let sortedDictionary = dictionary.sorted { a, b in
221253
a.key.localizedCaseInsensitiveCompare(b.key) == .orderedAscending
@@ -248,8 +280,18 @@ extension URISerializer {
248280
guard case .deepObject = configuration.style else { return elementKey }
249281
return rootKey + "[" + elementKey + "]"
250282
}
251-
func serializeNext(_ element: URIEncodedNode.Primitive, forKey elementKey: String) throws {
252-
try serializePrimitiveKeyValuePair(element, forKey: elementKey, separator: keyAndValueSeparator)
283+
func serializeNext(_ element: URIEncodedNode.PrimitiveOrArrayOfPrimitives, forKey elementKey: String) throws {
284+
switch element {
285+
case .primitive(let primitive):
286+
try serializePrimitiveKeyValuePair(primitive, forKey: elementKey, separator: keyAndValueSeparator)
287+
case .arrayOfPrimitives(let array):
288+
guard !array.isEmpty else { return }
289+
for item in array.dropLast() {
290+
try serializePrimitiveKeyValuePair(item, forKey: elementKey, separator: keyAndValueSeparator)
291+
data.append(pairSeparator)
292+
}
293+
try serializePrimitiveKeyValuePair(array.last!, forKey: elementKey, separator: keyAndValueSeparator)
294+
}
253295
}
254296
if let containerKeyAndValue = configuration.containerKeyAndValueSeparator {
255297
data.append(try stringifiedKey(key))

Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime {
2323
var color: SimpleEnum?
2424
}
2525

26+
struct StructWithArray: Decodable, Equatable {
27+
var foo: String
28+
var bar: [Int]?
29+
var val: [String]
30+
}
31+
2632
enum SimpleEnum: String, Decodable, Equatable {
2733
case red
2834
case green
@@ -59,6 +65,13 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime {
5965
// A struct.
6066
try test(["foo": ["bar"]], SimpleStruct(foo: "bar"), key: "root")
6167

68+
// A struct with an array property.
69+
try test(
70+
["foo": ["bar"], "bar": ["1", "2"], "val": ["baz", "baq"]],
71+
StructWithArray(foo: "bar", bar: [1, 2], val: ["baz", "baq"]),
72+
key: "root"
73+
)
74+
6275
// A struct with a nested enum.
6376
try test(["foo": ["bar"], "color": ["blue"]], SimpleStruct(foo: "bar", color: .blue), key: "root")
6477

Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ final class Test_URIValueToNodeEncoder: Test_Runtime {
4141
var val: SimpleEnum?
4242
}
4343

44+
struct StructWithArray: Encodable {
45+
var foo: String
46+
var bar: [Int]?
47+
var val: [String]
48+
}
49+
4450
struct NestedStruct: Encodable { var simple: SimpleStruct }
4551

4652
let cases: [Case] = [
@@ -89,6 +95,16 @@ final class Test_URIValueToNodeEncoder: Test_Runtime {
8995
.dictionary(["foo": .primitive(.string("bar")), "val": .primitive(.string("foo"))])
9096
),
9197

98+
// A struct with an array property.
99+
makeCase(
100+
StructWithArray(foo: "bar", bar: [1, 2], val: ["baz", "baq"]),
101+
.dictionary([
102+
"foo": .primitive(.string("bar")),
103+
"bar": .array([.primitive(.integer(1)), .primitive(.integer(2))]),
104+
"val": .array([.primitive(.string("baz")), .primitive(.string("baq"))]),
105+
])
106+
),
107+
92108
// A nested struct.
93109
makeCase(
94110
NestedStruct(simple: SimpleStruct(foo: "bar")),

Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -79,33 +79,31 @@ final class Test_URIParser: Test_Runtime {
7979
simpleUnexplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]),
8080
formDataExplode: "list=red&list=green&list=blue",
8181
formDataUnexplode: "list=red,green,blue",
82-
deepObjectExplode: .custom(
83-
"object%5Blist%5D=red&object%5Blist%5D=green&object%5Blist%5D=blue",
84-
expectedError: .malformedKeyValuePair("list")
85-
)
82+
deepObjectExplode: "object%5Blist%5D=red&object%5Blist%5D=green&object%5Blist%5D=blue"
8683
),
8784
value: ["list": ["red", "green", "blue"]]
8885
),
8986
makeCase(
9087
.init(
91-
formExplode: "comma=%2C&dot=.&semi=%3B",
88+
formExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B",
9289
formUnexplode: .custom(
93-
"keys=comma,%2C,dot,.,semi,%3B",
94-
value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]]
90+
"keys=comma,%2C,dot,.,list,one,list,two,semi,%3B",
91+
value: ["keys": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]]
9592
),
96-
simpleExplode: "comma=%2C,dot=.,semi=%3B",
93+
simpleExplode: "comma=%2C,dot=.,list=one,list=two,semi=%3B",
9794
simpleUnexplode: .custom(
98-
"comma,%2C,dot,.,semi,%3B",
99-
value: ["": ["comma", ",", "dot", ".", "semi", ";"]]
95+
"comma,%2C,dot,.,list,one,list,two,semi,%3B",
96+
value: ["": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]]
10097
),
101-
formDataExplode: "comma=%2C&dot=.&semi=%3B",
98+
formDataExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B",
10299
formDataUnexplode: .custom(
103-
"keys=comma,%2C,dot,.,semi,%3B",
104-
value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]]
100+
"keys=comma,%2C,dot,.,list,one,list,two,semi,%3B",
101+
value: ["keys": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]]
105102
),
106-
deepObjectExplode: "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Bsemi%5D=%3B"
103+
deepObjectExplode:
104+
"keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Blist%5D=one&keys%5Blist%5D=two&keys%5Bsemi%5D=%3B"
107105
),
108-
value: ["semi": [";"], "dot": ["."], "comma": [","]]
106+
value: ["semi": [";"], "dot": ["."], "comma": [","], "list": ["one", "two"]]
109107
),
110108
]
111109
for testCase in cases {

Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,16 +126,18 @@ final class Test_URISerializer: Test_Runtime {
126126
value: .dictionary([
127127
"semi": .primitive(.string(";")), "dot": .primitive(.string(".")),
128128
"comma": .primitive(.string(",")),
129+
"list": .array([.primitive(.string("one")), .primitive(.string("two"))]),
129130
]),
130131
key: "keys",
131132
.init(
132-
formExplode: "comma=%2C&dot=.&semi=%3B",
133-
formUnexplode: "keys=comma,%2C,dot,.,semi,%3B",
134-
simpleExplode: "comma=%2C,dot=.,semi=%3B",
135-
simpleUnexplode: "comma,%2C,dot,.,semi,%3B",
136-
formDataExplode: "comma=%2C&dot=.&semi=%3B",
137-
formDataUnexplode: "keys=comma,%2C,dot,.,semi,%3B",
138-
deepObjectExplode: "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Bsemi%5D=%3B"
133+
formExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B",
134+
formUnexplode: "keys=comma,%2C,dot,.,list,one,list,two,semi,%3B",
135+
simpleExplode: "comma=%2C,dot=.,list=one,list=two,semi=%3B",
136+
simpleUnexplode: "comma,%2C,dot,.,list,one,list,two,semi,%3B",
137+
formDataExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B",
138+
formDataUnexplode: "keys=comma,%2C,dot,.,list,one,list,two,semi,%3B",
139+
deepObjectExplode:
140+
"keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Blist%5D=one&keys%5Blist%5D=two&keys%5Bsemi%5D=%3B"
139141
)
140142
),
141143
]

0 commit comments

Comments
 (0)