Skip to content

Commit

Permalink
Support nested arrays of primitive values inside of objects (#120)
Browse files Browse the repository at this point in the history
### 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.
  • Loading branch information
czechboy0 authored Oct 3, 2024
1 parent 82b474f commit da2e5b8
Show file tree
Hide file tree
Showing 7 changed files with 111 additions and 31 deletions.
10 changes: 10 additions & 0 deletions Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ enum URIEncodedNode: Equatable {
/// A date value.
case date(Date)
}

/// A primitive value or an array of primitive values.
enum PrimitiveOrArrayOfPrimitives: Equatable {

/// A primitive value.
case primitive(Primitive)

/// An array of primitive values.
case arrayOfPrimitives([Primitive])
}
}

extension URIEncodedNode {
Expand Down
1 change: 0 additions & 1 deletion Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,6 @@ extension URIParser {
appendPair(key, [value])
}
}
for (key, value) in parseNode where value.count > 1 { throw ParsingError.malformedKeyValuePair(key) }
return parseNode
}
}
Expand Down
58 changes: 50 additions & 8 deletions Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ extension CharacterSet {
extension URISerializer {

/// A serializer error.
enum SerializationError: Swift.Error, Hashable {

enum SerializationError: Swift.Error, Hashable, CustomStringConvertible, LocalizedError {
/// Nested containers are not supported.
case nestedContainersNotSupported
/// Deep object arrays are not supported.
Expand All @@ -75,6 +74,28 @@ extension URISerializer {
case deepObjectsWithPrimitiveValuesNotSupported
/// An invalid configuration was detected.
case invalidConfiguration(String)

/// A human-readable description of the serialization error.
///
/// This computed property returns a string that includes information about the serialization error.
///
/// - Returns: A string describing the serialization error and its associated details.
var description: String {
switch self {
case .nestedContainersNotSupported: "URISerializer: Nested containers are not supported"
case .deepObjectsArrayNotSupported: "URISerializer: Deep object arrays are not supported"
case .deepObjectsWithPrimitiveValuesNotSupported:
"URISerializer: Deep object with primitive values are not supported"
case .invalidConfiguration(let string): "URISerializer: Invalid configuration: \(string)"
}
}

/// A localized description of the serialization error.
///
/// This computed property provides a localized human-readable description of the serialization error, which is suitable for displaying to users.
///
/// - Returns: A localized string describing the serialization error.
var errorDescription: String? { description }
}

/// Computes an escaped version of the provided string.
Expand Down Expand Up @@ -114,6 +135,16 @@ extension URISerializer {
guard case let .primitive(primitive) = node else { throw SerializationError.nestedContainersNotSupported }
return primitive
}
func unwrapPrimitiveOrArrayOfPrimitives(_ node: URIEncodedNode) throws
-> URIEncodedNode.PrimitiveOrArrayOfPrimitives
{
if case let .primitive(primitive) = node { return .primitive(primitive) }
if case let .array(array) = node {
let primitives = try array.map(unwrapPrimitiveValue)
return .arrayOfPrimitives(primitives)
}
throw SerializationError.nestedContainersNotSupported
}
switch value {
case .unset:
// Nothing to serialize.
Expand All @@ -128,7 +159,7 @@ extension URISerializer {
try serializePrimitiveKeyValuePair(primitive, forKey: key, separator: keyAndValueSeparator)
case .array(let array): try serializeArray(array.map(unwrapPrimitiveValue), forKey: key)
case .dictionary(let dictionary):
try serializeDictionary(dictionary.mapValues(unwrapPrimitiveValue), forKey: key)
try serializeDictionary(dictionary.mapValues(unwrapPrimitiveOrArrayOfPrimitives), forKey: key)
}
}

Expand Down Expand Up @@ -213,9 +244,10 @@ extension URISerializer {
/// - key: The key to serialize the value under (details depend on the
/// style and explode parameters in the configuration).
/// - Throws: An error if serialization of the dictionary fails.
private mutating func serializeDictionary(_ dictionary: [String: URIEncodedNode.Primitive], forKey key: String)
throws
{
private mutating func serializeDictionary(
_ dictionary: [String: URIEncodedNode.PrimitiveOrArrayOfPrimitives],
forKey key: String
) throws {
guard !dictionary.isEmpty else { return }
let sortedDictionary = dictionary.sorted { a, b in
a.key.localizedCaseInsensitiveCompare(b.key) == .orderedAscending
Expand Down Expand Up @@ -248,8 +280,18 @@ extension URISerializer {
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)
func serializeNext(_ element: URIEncodedNode.PrimitiveOrArrayOfPrimitives, forKey elementKey: String) throws {
switch element {
case .primitive(let primitive):
try serializePrimitiveKeyValuePair(primitive, forKey: elementKey, separator: keyAndValueSeparator)
case .arrayOfPrimitives(let array):
guard !array.isEmpty else { return }
for item in array.dropLast() {
try serializePrimitiveKeyValuePair(item, forKey: elementKey, separator: keyAndValueSeparator)
data.append(pairSeparator)
}
try serializePrimitiveKeyValuePair(array.last!, forKey: elementKey, separator: keyAndValueSeparator)
}
}
if let containerKeyAndValue = configuration.containerKeyAndValueSeparator {
data.append(try stringifiedKey(key))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime {
var color: SimpleEnum?
}

struct StructWithArray: Decodable, Equatable {
var foo: String
var bar: [Int]?
var val: [String]
}

enum SimpleEnum: String, Decodable, Equatable {
case red
case green
Expand Down Expand Up @@ -59,6 +65,13 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime {
// A struct.
try test(["foo": ["bar"]], SimpleStruct(foo: "bar"), key: "root")

// A struct with an array property.
try test(
["foo": ["bar"], "bar": ["1", "2"], "val": ["baz", "baq"]],
StructWithArray(foo: "bar", bar: [1, 2], val: ["baz", "baq"]),
key: "root"
)

// A struct with a nested enum.
try test(["foo": ["bar"], "color": ["blue"]], SimpleStruct(foo: "bar", color: .blue), key: "root")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ final class Test_URIValueToNodeEncoder: Test_Runtime {
var val: SimpleEnum?
}

struct StructWithArray: Encodable {
var foo: String
var bar: [Int]?
var val: [String]
}

struct NestedStruct: Encodable { var simple: SimpleStruct }

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

// A struct with an array property.
makeCase(
StructWithArray(foo: "bar", bar: [1, 2], val: ["baz", "baq"]),
.dictionary([
"foo": .primitive(.string("bar")),
"bar": .array([.primitive(.integer(1)), .primitive(.integer(2))]),
"val": .array([.primitive(.string("baz")), .primitive(.string("baq"))]),
])
),

// A nested struct.
makeCase(
NestedStruct(simple: SimpleStruct(foo: "bar")),
Expand Down
28 changes: 13 additions & 15 deletions Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,33 +79,31 @@ final class Test_URIParser: Test_Runtime {
simpleUnexplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]),
formDataExplode: "list=red&list=green&list=blue",
formDataUnexplode: "list=red,green,blue",
deepObjectExplode: .custom(
"object%5Blist%5D=red&object%5Blist%5D=green&object%5Blist%5D=blue",
expectedError: .malformedKeyValuePair("list")
)
deepObjectExplode: "object%5Blist%5D=red&object%5Blist%5D=green&object%5Blist%5D=blue"
),
value: ["list": ["red", "green", "blue"]]
),
makeCase(
.init(
formExplode: "comma=%2C&dot=.&semi=%3B",
formExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B",
formUnexplode: .custom(
"keys=comma,%2C,dot,.,semi,%3B",
value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]]
"keys=comma,%2C,dot,.,list,one,list,two,semi,%3B",
value: ["keys": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]]
),
simpleExplode: "comma=%2C,dot=.,semi=%3B",
simpleExplode: "comma=%2C,dot=.,list=one,list=two,semi=%3B",
simpleUnexplode: .custom(
"comma,%2C,dot,.,semi,%3B",
value: ["": ["comma", ",", "dot", ".", "semi", ";"]]
"comma,%2C,dot,.,list,one,list,two,semi,%3B",
value: ["": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]]
),
formDataExplode: "comma=%2C&dot=.&semi=%3B",
formDataExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B",
formDataUnexplode: .custom(
"keys=comma,%2C,dot,.,semi,%3B",
value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]]
"keys=comma,%2C,dot,.,list,one,list,two,semi,%3B",
value: ["keys": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]]
),
deepObjectExplode: "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Bsemi%5D=%3B"
deepObjectExplode:
"keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Blist%5D=one&keys%5Blist%5D=two&keys%5Bsemi%5D=%3B"
),
value: ["semi": [";"], "dot": ["."], "comma": [","]]
value: ["semi": [";"], "dot": ["."], "comma": [","], "list": ["one", "two"]]
),
]
for testCase in cases {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,16 +126,18 @@ final class Test_URISerializer: Test_Runtime {
value: .dictionary([
"semi": .primitive(.string(";")), "dot": .primitive(.string(".")),
"comma": .primitive(.string(",")),
"list": .array([.primitive(.string("one")), .primitive(.string("two"))]),
]),
key: "keys",
.init(
formExplode: "comma=%2C&dot=.&semi=%3B",
formUnexplode: "keys=comma,%2C,dot,.,semi,%3B",
simpleExplode: "comma=%2C,dot=.,semi=%3B",
simpleUnexplode: "comma,%2C,dot,.,semi,%3B",
formDataExplode: "comma=%2C&dot=.&semi=%3B",
formDataUnexplode: "keys=comma,%2C,dot,.,semi,%3B",
deepObjectExplode: "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Bsemi%5D=%3B"
formExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B",
formUnexplode: "keys=comma,%2C,dot,.,list,one,list,two,semi,%3B",
simpleExplode: "comma=%2C,dot=.,list=one,list=two,semi=%3B",
simpleUnexplode: "comma,%2C,dot,.,list,one,list,two,semi,%3B",
formDataExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B",
formDataUnexplode: "keys=comma,%2C,dot,.,list,one,list,two,semi,%3B",
deepObjectExplode:
"keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Blist%5D=one&keys%5Blist%5D=two&keys%5Bsemi%5D=%3B"
)
),
]
Expand Down

0 comments on commit da2e5b8

Please sign in to comment.