diff --git a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift index 07aa6092..31dda63c 100644 --- a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift +++ b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift @@ -14,7 +14,7 @@ /// The serialization style used by a parameter. /// -/// Details: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#fixed-fields-10 +/// Details: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.4.md#fixed-fields-10 @_spi(Generated) public enum ParameterStyle: Sendable { /// The form style. @@ -26,9 +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 + /// Details: https://spec.openapis.org/oas/v3.0.4.html#style-values case deepObject } diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift b/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift index ccbdb8c5..3f7b380e 100644 --- a/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift +++ b/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift @@ -17,15 +17,22 @@ import Foundation /// A bag of configuration values used by the URI encoder and decoder. struct URICoderConfiguration { - /// A variable expansion style as described by RFC 6570 and OpenAPI 3.0.3. + /// A variable expansion style as described by RFC 6570 and OpenAPI 3.0.4. enum Style { /// A style for simple string variable expansion. + /// + /// The whole string always belongs to the root key. case simple /// A style for form-based URI expansion. + /// + /// Only some key/value pairs can belong to the root key, rest are ignored. case form + /// A style for nested variable expansion + /// + /// Only some key/value pairs can belong to the root key, rest are ignored. case deepObject } @@ -43,7 +50,7 @@ struct URICoderConfiguration { var style: Style /// A Boolean value indicating whether the key should be repeated with - /// each value, as described by RFC 6570 and OpenAPI 3.0.3. + /// each value, as described by RFC 6570 and OpenAPI 3.0.4. var explode: Bool /// The character used to escape the space character. diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift b/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift index 4297f778..d2f9edbb 100644 --- a/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift +++ b/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift @@ -73,6 +73,10 @@ extension URIEncodedNode { /// The encoder appended to a node that wasn't an array. case appendingToNonArrayContainer + /// The encoder is trying to mark a container as array, but it's already + /// marked as a container of another type. + case markingExistingNonArrayContainerAsArray + /// The encoder inserted a value for key into a node that wasn't /// a dictionary. case insertingChildValueIntoNonContainer @@ -128,6 +132,18 @@ extension URIEncodedNode { } } + /// Marks the node as an array, starting as empty. + /// - Throws: If the node is already set to be anything else but an array. + mutating func markAsArray() throws { + switch self { + case .array: + // Already an array. + break + case .unset: self = .array([]) + default: throw InsertionError.markingExistingNonArrayContainerAsArray + } + } + /// Appends a value to the array node. /// - Parameter childValue: The node to append to the underlying array. /// - Throws: If the node is already set to be anything else but an array. diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift b/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift deleted file mode 100644 index 51ecbe2a..00000000 --- a/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift +++ /dev/null @@ -1,27 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Foundation - -/// The type used for keys by `URIParser`. -typealias URIParsedKey = String.SubSequence - -/// The type used for values by `URIParser`. -typealias URIParsedValue = String.SubSequence - -/// The type used for an array of values by `URIParser`. -typealias URIParsedValueArray = [URIParsedValue] - -/// The type used for a node and a dictionary by `URIParser`. -typealias URIParsedNode = [URIParsedKey: URIParsedValueArray] diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URIParsedTypes.swift b/Sources/OpenAPIRuntime/URICoder/Common/URIParsedTypes.swift new file mode 100644 index 00000000..23d54e65 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Common/URIParsedTypes.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// A component of a `URIParsedKey`. +typealias URIParsedKeyComponent = String.SubSequence + +/// A parsed key for a parsed value. +/// +/// For example, `foo=bar` in a `form` string would parse the key as `foo` (single component). +/// In an unexploded `form` string `root=foo,bar`, the key would be `root/foo` (two components). +/// In a `simple` string `bar`, the key would be empty (0 components). +struct URIParsedKey: Hashable { + + /// The individual string components. + let components: [URIParsedKeyComponent] + + /// Creates a new parsed key. + /// - Parameter components: The key components. + init(_ components: [URIParsedKeyComponent]) { self.components = components } + + /// A new empty key. + static var empty: Self { .init([]) } +} + +/// A primitive value produced by `URIParser`. +typealias URIParsedValue = String.SubSequence + +/// A key-value produced by `URIParser`. +struct URIParsedPair: Equatable { + + /// The key of the pair. + /// + /// In `foo=bar`, `foo` is the key. + var key: URIParsedKey + + /// The value of the pair. + /// + /// In `foo=bar`, `bar` is the value. + var value: URIParsedValue +} + +// MARK: - Extensions + +extension URIParsedKey: CustomStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + var description: String { + if components.isEmpty { return "" } + return components.joined(separator: "/") + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift index 72cc077f..3be1dce1 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift @@ -15,7 +15,7 @@ import Foundation /// A type that decodes a `Decodable` value from an URI-encoded string -/// using the rules from RFC 6570, RFC 1866, and OpenAPI 3.0.3, depending on +/// using the rules from RFC 6570, RFC 1866, and OpenAPI 3.0.4, depending on /// the configuration. /// /// [RFC 6570 - Form-style query expansion.](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8) @@ -45,6 +45,13 @@ import Foundation /// | `{list\*}` | `red,green,blue` | /// | `{keys}` | `semi,%3B,dot,.,comma,%2C` | /// | `{keys\*}` | `semi=%3B,dot=.,comma=%2C` | +/// +/// [OpenAPI 3.0.4 - Deep object expansion.](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.4.md#style-examples) +/// +/// | Example Template | Expansion | +/// | ---------------- | ----------------------------------------------------------| +/// | `{?keys\*}` | `?keys%5Bsemi%5D=%3B&keys%5Bdot%5D=.&keys%5Bcomma%5D=%2C` | +/// struct URIDecoder: Sendable { /// The configuration instructing the decoder how to interpret the raw @@ -60,10 +67,6 @@ extension URIDecoder { /// Attempt to decode an object from an URI string. /// - /// Under the hood, `URIDecoder` first parses the string into a - /// `URIParsedNode` using `URIParser`, and then uses - /// `URIValueFromNodeDecoder` to decode the `Decodable` value. - /// /// - Parameters: /// - type: The type to decode. /// - key: The key of the decoded value. Only used with certain styles @@ -72,15 +75,12 @@ extension URIDecoder { /// - Returns: The decoded value. /// - Throws: An error if decoding fails, for example, due to incompatible data or key. func decode(_ type: T.Type = T.self, forKey key: String = "", from data: Substring) throws -> T { - try withCachedParser(from: data) { decoder in try decoder.decode(type, forKey: key) } + let decoder = URIValueFromNodeDecoder(data: data, rootKey: key[...], configuration: configuration) + return try decoder.decodeRoot(type) } /// Attempt to decode an object from an URI string, if present. /// - /// Under the hood, `URIDecoder` first parses the string into a - /// `URIParsedNode` using `URIParser`, and then uses - /// `URIValueFromNodeDecoder` to decode the `Decodable` value. - /// /// - Parameters: /// - type: The type to decode. /// - key: The key of the decoded value. Only used with certain styles @@ -90,76 +90,8 @@ extension URIDecoder { /// - Throws: An error if decoding fails, for example, due to incompatible data or key. func decodeIfPresent(_ type: T.Type = T.self, forKey key: String = "", from data: Substring) throws -> T? - { try withCachedParser(from: data) { decoder in try decoder.decodeIfPresent(type, forKey: key) } } - - /// Make multiple decode calls on the parsed URI. - /// - /// Use to avoid repeatedly reparsing the raw string. - /// - Parameters: - /// - data: The URI-encoded string. - /// - calls: The closure that contains 0 or more calls to - /// the `decode` method on `URICachedDecoder`. - /// - Returns: The result of the closure invocation. - /// - Throws: An error if parsing or decoding fails. - func withCachedParser(from data: Substring, calls: (URICachedDecoder) throws -> R) throws -> R { - var parser = URIParser(configuration: configuration, data: data) - let parsedNode = try parser.parseRoot() - let decoder = URICachedDecoder(configuration: configuration, node: parsedNode) - return try calls(decoder) - } -} - -struct URICachedDecoder { - - /// The configuration used by the decoder. - fileprivate let configuration: URICoderConfiguration - - /// The node from which to decode a value on demand. - fileprivate let node: URIParsedNode - - /// Attempt to decode an object from an URI-encoded string. - /// - /// Under the hood, `URICachedDecoder` already has a pre-parsed - /// `URIParsedNode` and uses `URIValueFromNodeDecoder` to decode - /// the `Decodable` value. - /// - /// - Parameters: - /// - type: The type to decode. - /// - key: The key of the decoded value. Only used with certain styles - /// and explode options, ignored otherwise. - /// - Returns: The decoded value. - /// - Throws: An error if decoding fails. - func decode(_ type: T.Type = T.self, forKey key: String = "") throws -> T { - let decoder = URIValueFromNodeDecoder( - node: node, - rootKey: key[...], - style: configuration.style, - explode: configuration.explode, - dateTranscoder: configuration.dateTranscoder - ) - return try decoder.decodeRoot() - } - - /// Attempt to decode an object from an URI-encoded string, if present. - /// - /// Under the hood, `URICachedDecoder` already has a pre-parsed - /// `URIParsedNode` and uses `URIValueFromNodeDecoder` to decode - /// the `Decodable` value. - /// - /// - Parameters: - /// - type: The type to decode. - /// - key: The key of the decoded value. Only used with certain styles - /// and explode options, ignored otherwise. - /// - Returns: The decoded value. - /// - Throws: An error if decoding fails. - func decodeIfPresent(_ type: T.Type = T.self, forKey key: String = "") throws -> T? { - let decoder = URIValueFromNodeDecoder( - node: node, - rootKey: key[...], - style: configuration.style, - explode: configuration.explode, - dateTranscoder: configuration.dateTranscoder - ) - return try decoder.decodeRootIfPresent() + { + let decoder = URIValueFromNodeDecoder(data: data, rootKey: key[...], configuration: configuration) + return try decoder.decodeRootIfPresent(type) } } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift index fd47d462..3e3990f6 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift @@ -19,9 +19,6 @@ struct URIKeyedDecodingContainer { /// The associated decoder. let decoder: URIValueFromNodeDecoder - - /// The underlying dictionary. - let values: URIParsedNode } extension URIKeyedDecodingContainer { @@ -32,7 +29,7 @@ extension URIKeyedDecodingContainer { /// - Returns: The value found for the provided key. /// - Throws: An error if no value for the key was found. private func _decodeValue(forKey key: Key) throws -> URIParsedValue { - guard let value = values[key.stringValue[...]]?.first else { + guard let value = try decoder.nestedElementInCurrentDictionary(forKey: key.stringValue) else { throw DecodingError.keyNotFound(key, .init(codingPath: codingPath, debugDescription: "Key not found.")) } return value @@ -97,9 +94,9 @@ extension URIKeyedDecodingContainer { extension URIKeyedDecodingContainer: KeyedDecodingContainerProtocol { - var allKeys: [Key] { values.keys.map { key in Key.init(stringValue: String(key))! } } + var allKeys: [Key] { decoder.elementKeysInCurrentDictionary().compactMap { .init(stringValue: $0) } } - func contains(_ key: Key) -> Bool { values[key.stringValue[...]] != nil } + func contains(_ key: Key) -> Bool { decoder.containsElementInCurrentDictionary(forKey: key.stringValue) } var codingPath: [any CodingKey] { decoder.codingPath } @@ -153,7 +150,7 @@ extension URIKeyedDecodingContainer: KeyedDecodingContainerProtocol { case is UInt64.Type: return try decode(UInt64.self, forKey: key) as! T case is Date.Type: return try decoder.dateTranscoder.decode(String(_decodeValue(forKey: key))) as! T default: - try decoder.push(.init(key)) + decoder.push(.init(key)) defer { decoder.pop() } return try type.init(from: decoder) } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift index 3c829873..2207bd84 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift @@ -24,7 +24,12 @@ struct URISingleValueDecodingContainer { extension URISingleValueDecodingContainer { /// The underlying value as a single value. - var value: URIParsedValue { get throws { try decoder.currentElementAsSingleValue() } } + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form decoder (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + var value: URIParsedValue? { get throws { try decoder.currentElementAsSingleValue() } } /// Returns the value found in the underlying node converted to /// the provided type. @@ -33,7 +38,17 @@ extension URISingleValueDecodingContainer { /// - Returns: The converted value found. /// - Throws: An error if the conversion failed. private func _decodeBinaryFloatingPoint(_: T.Type = T.self) throws -> T { - guard let double = try Double(value) else { + guard let value = try value else { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + guard let double = Double(value) else { throw DecodingError.typeMismatch( T.self, .init(codingPath: codingPath, debugDescription: "Failed to convert to Double.") @@ -49,7 +64,17 @@ extension URISingleValueDecodingContainer { /// - Returns: The converted value found. /// - Throws: An error if the conversion failed. private func _decodeFixedWidthInteger(_: T.Type = T.self) throws -> T { - guard let parsedValue = try T(value) else { + guard let value = try value else { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + guard let parsedValue = T(value) else { throw DecodingError.typeMismatch( T.self, .init(codingPath: codingPath, debugDescription: "Failed to convert to the requested type.") @@ -65,7 +90,17 @@ extension URISingleValueDecodingContainer { /// - Returns: The converted value found. /// - Throws: An error if the conversion failed. private func _decodeLosslessStringConvertible(_: T.Type = T.self) throws -> T { - guard let parsedValue = try T(String(value)) else { + guard let value = try value else { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + guard let parsedValue = T(String(value)) else { throw DecodingError.typeMismatch( T.self, .init(codingPath: codingPath, debugDescription: "Failed to convert to the requested type.") @@ -79,11 +114,23 @@ extension URISingleValueDecodingContainer: SingleValueDecodingContainer { var codingPath: [any CodingKey] { decoder.codingPath } - func decodeNil() -> Bool { false } + func decodeNil() -> Bool { do { return try value == nil } catch { return false } } func decode(_ type: Bool.Type) throws -> Bool { try _decodeLosslessStringConvertible() } - func decode(_ type: String.Type) throws -> String { try String(value) } + func decode(_ type: String.Type) throws -> String { + guard let value = try value else { + throw DecodingError.valueNotFound( + String.self, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + return String(value) + } func decode(_ type: Double.Type) throws -> Double { try _decodeBinaryFloatingPoint() } @@ -125,7 +172,18 @@ extension URISingleValueDecodingContainer: SingleValueDecodingContainer { case is UInt16.Type: return try decode(UInt16.self) as! T case is UInt32.Type: return try decode(UInt32.self) as! T case is UInt64.Type: return try decode(UInt64.self) as! T - case is Date.Type: return try decoder.dateTranscoder.decode(String(value)) as! T + case is Date.Type: + guard let value = try value else { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + return try decoder.dateTranscoder.decode(String(value)) as! T default: return try T.init(from: decoder) } } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift index 44a5cd28..72fe4739 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift @@ -16,24 +16,17 @@ import Foundation /// An unkeyed container used by `URIValueFromNodeDecoder`. struct URIUnkeyedDecodingContainer { - /// The associated decoder. let decoder: URIValueFromNodeDecoder - /// The underlying array. - let values: URIParsedValueArray - - /// The index of the item being currently decoded. - private var index: Int + /// The index of the next item to be decoded. + private(set) var currentIndex: Int /// Creates a new unkeyed container ready to decode the first key. - /// - Parameters: - /// - decoder: The underlying decoder. - /// - values: The underlying array. - init(decoder: URIValueFromNodeDecoder, values: URIParsedValueArray) { + /// - Parameter decoder: The underlying decoder. + init(decoder: URIValueFromNodeDecoder) { self.decoder = decoder - self.values = values - self.index = values.startIndex + self.currentIndex = 0 } } @@ -46,7 +39,7 @@ extension URIUnkeyedDecodingContainer { /// - Throws: An error if the container ran out of items. private mutating func _decodingNext(in work: () throws -> R) throws -> R { guard !isAtEnd else { throw URIValueFromNodeDecoder.GeneralError.reachedEndOfUnkeyedContainer } - defer { values.formIndex(after: &index) } + defer { currentIndex += 1 } return try work() } @@ -55,7 +48,7 @@ extension URIUnkeyedDecodingContainer { /// - Returns: The next value found. /// - Throws: An error if the container ran out of items. private mutating func _decodeNext() throws -> URIParsedValue { - try _decodingNext { [values, index] in values[index] } + try _decodingNext { [decoder, currentIndex] in try decoder.nestedElementInCurrentArray(atIndex: currentIndex) } } /// Returns the next value converted to the provided type. @@ -111,11 +104,9 @@ extension URIUnkeyedDecodingContainer { extension URIUnkeyedDecodingContainer: UnkeyedDecodingContainer { - var count: Int? { values.count } - - var isAtEnd: Bool { index == values.endIndex } + var count: Int? { try? decoder.countOfCurrentArray() } - var currentIndex: Int { index } + var isAtEnd: Bool { currentIndex == count } var codingPath: [any CodingKey] { decoder.codingPath } @@ -168,7 +159,7 @@ extension URIUnkeyedDecodingContainer: UnkeyedDecodingContainer { case is Date.Type: return try decoder.dateTranscoder.decode(String(_decodeNext())) as! T default: return try _decodingNext { [decoder, currentIndex] in - try decoder.push(.init(intValue: currentIndex)) + decoder.push(.init(intValue: currentIndex)) defer { decoder.pop() } return try type.init(from: decoder) } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift index 55982755..83c77c90 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift @@ -14,46 +14,54 @@ import Foundation -/// A type that allows decoding `Decodable` values from a `URIParsedNode`. +/// A type that allows decoding `Decodable` values from a URI-encoded string. final class URIValueFromNodeDecoder { - /// The coder used for serializing Date values. + /// The key of the root object. + let rootKey: URIParsedKeyComponent + + /// The date transcoder used for decoding the `Date` type. let dateTranscoder: any DateTranscoder - /// The underlying root node. - private let node: URIParsedNode + /// The coder configuration. + private let configuration: URICoderConfiguration + + /// The URIParser used to parse the provided URI-encoded string into + /// an intermediate representation. + private let parser: URIParser + + /// The cached parsing state of the decoder. + private struct ParsingCache { + + /// The cached result of parsing the string as a primitive value. + var primitive: Result? - /// The key of the root value in the node. - private let rootKey: URIParsedKey + /// The cached result of parsing the string as an array. + var array: Result<[URIParsedValue], any Error>? - /// The variable expansion style. - private let style: URICoderConfiguration.Style + /// The cached result of parsing the string as a dictionary. + var dictionary: Result<[URIParsedKeyComponent: [URIParsedValue]], any Error>? + } - /// The explode parameter of the expansion style. - private let explode: Bool + /// A cache holding the parsed intermediate representation. + private var cache: ParsingCache - /// The stack of nested values within the root node. - private var codingStack: [CodingStackEntry] + /// The stack of nested keys within the root node. + /// + /// Represents the currently parsed container. + private var codingStack: [URICoderCodingKey] /// Creates a new decoder. /// - Parameters: - /// - node: The underlying root node. - /// - rootKey: The key of the root value in the node. - /// - style: The variable expansion style. - /// - explode: The explode parameter of the expansion style. - /// - dateTranscoder: The coder used for serializing Date values. - init( - node: URIParsedNode, - rootKey: URIParsedKey, - style: URICoderConfiguration.Style, - explode: Bool, - dateTranscoder: any DateTranscoder - ) { - self.node = node + /// - data: The data to parse. + /// - rootKey: The key of the root object. + /// - configuration: The configuration of the decoder. + init(data: Substring, rootKey: URIParsedKeyComponent, configuration: URICoderConfiguration) { self.rootKey = rootKey - self.style = style - self.explode = explode - self.dateTranscoder = dateTranscoder + self.dateTranscoder = configuration.dateTranscoder + self.configuration = configuration + self.parser = .init(configuration: configuration, data: data) + self.cache = .init() self.codingStack = [] } @@ -76,13 +84,22 @@ final class URIValueFromNodeDecoder { return value } - /// Decodes the provided type from the root node. + /// Decodes the provided type from the root node, if it's present. /// - Parameter type: The type to decode from the decoder. - /// - Returns: The decoded value. + /// - Returns: The decoded value, or nil if not found. /// - Throws: When a decoding error occurs. func decodeRootIfPresent(_ type: T.Type = T.self) throws -> T? { - // The root is only nil if the node is empty. - if try currentElementAsArray().isEmpty { return nil } + switch configuration.style { + case .simple: + // Root is never nil, empty data just means an element with an empty string. + break + case .form: + // Try to parse as an array, check the number of elements. + if try parsedRootAsArray().count == 0 { return nil } + case .deepObject: + // Try to parse as a dictionary, check the number of elements. + if try parsedRootAsDictionary().count == 0 { return nil } + } return try decodeRoot(type) } } @@ -98,62 +115,13 @@ extension URIValueFromNodeDecoder { /// The decoder was asked for more items, but it was already at the /// end of the unkeyed container. case reachedEndOfUnkeyedContainer - - /// The provided coding key does not have a valid integer value, but - /// it is being used for accessing items in an unkeyed container. - case codingKeyNotInt - - /// The provided coding key is out of bounds of the unkeyed container. - case codingKeyOutOfBounds - - /// The coding key is of a value not found in the keyed container. - case codingKeyNotFound } - /// A node materialized by the decoder. - private enum URIDecodedNode { - - /// A single value. - case single(URIParsedValue) - - /// An array of values. - case array(URIParsedValueArray) - - /// A dictionary of values. - case dictionary(URIParsedNode) - } - - /// An entry in the coding stack for `URIValueFromNodeDecoder`. - /// - /// This is used to keep track of where we are in the decode. - private struct CodingStackEntry { - - /// The key at which the entry was found. - var key: URICoderCodingKey - - /// The node at the key inside its parent. - var element: URIDecodedNode - } - - /// The element at the current head of the coding stack. - private var currentElement: URIDecodedNode { codingStack.last?.element ?? .dictionary(node) } - /// Pushes a new container on top of the current stack, nesting into the /// value at the provided key. /// - Parameter codingKey: The coding key for the value that is then put /// at the top of the stack. - /// - Throws: An error if an issue occurs during the container push operation. - func push(_ codingKey: URICoderCodingKey) throws { - let nextElement: URIDecodedNode - if let intValue = codingKey.intValue { - let value = try nestedValueInCurrentElementAsArray(at: intValue) - nextElement = .single(value) - } else { - let values = try nestedValuesInCurrentElementAsDictionary(forKey: codingKey.stringValue) - nextElement = .array(values) - } - codingStack.append(CodingStackEntry(key: codingKey, element: nextElement)) - } + func push(_ codingKey: URICoderCodingKey) { codingStack.append(codingKey) } /// Pops the top container from the stack and restores the previously top /// container to be the current top container. @@ -167,153 +135,238 @@ extension URIValueFromNodeDecoder { throw DecodingError.typeMismatch(String.self, .init(codingPath: codingPath, debugDescription: message)) } - /// Extracts the root value of the provided node using the root key. - /// - Parameter node: The node which to expect for the root key. - /// - Returns: The value found at the root key in the provided node. - /// - Throws: A `DecodingError` if the value is not found at the root key - private func rootValue(in node: URIParsedNode) throws -> URIParsedValueArray { - guard let value = node[rootKey] else { - if style == .simple, let valueForFallbackKey = node[""] { - // The simple style doesn't encode the key, so single values - // get encoded as a value only, and parsed under the empty - // string key. - return valueForFallbackKey + // MARK: - withParsed methods + + /// Parse the root parsed as a specific type, with automatic caching. + /// - Parameters: + /// - valueKeyPath: A key path to the parsing cache for storing the cached value. + /// - parsingClosure: Gets the value from the parser. + /// - Returns: The parsed value. + /// - Throws: If the parsing closure fails. + private func cachedRoot( + as valueKeyPath: WritableKeyPath?>, + parse parsingClosure: (URIParser) throws -> ValueType + ) throws -> ValueType { + let value: ValueType + if let cached = cache[keyPath: valueKeyPath] { + value = try cached.get() + } else { + let result: Result + do { + value = try parsingClosure(parser) + result = .success(value) + } catch { + result = .failure(error) + throw error } - return [] + cache[keyPath: valueKeyPath] = result } return value } - /// Extracts the node at the top of the coding stack and tries to treat it - /// as a dictionary. - /// - Returns: The value if it can be treated as a dictionary. - /// - Throws: An error if the current element cannot be treated as a dictionary. - private func currentElementAsDictionary() throws -> URIParsedNode { try nodeAsDictionary(currentElement) } - - /// Checks if the provided node can be treated as a dictionary, and returns - /// it if so. - /// - Parameter node: The node to check. - /// - Returns: The value if it can be treated as a dictionary. - /// - Throws: An error if the node cannot be treated as a valid dictionary. - private func nodeAsDictionary(_ node: URIDecodedNode) throws -> URIParsedNode { - // There are multiple ways a valid dictionary is represented in a node, - // depends on the explode parameter. - // 1. exploded: Key-value pairs in the node: ["R":["100"]] - // 2. unexploded form: Flattened key-value pairs in the only top level - // key's value array: ["":["R","100"]] - // To simplify the code, when asked for a keyed container here and explode - // is false, we convert (2) to (1), and then treat everything as (1). - // The conversion only works if the number of values is even, including 0. - if explode { - guard case let .dictionary(values) = node else { - try throwMismatch("Cannot treat a single value or an array as a dictionary.") + /// Parse the root as a primitive value. + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form decoder (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + /// - Returns: The parsed value. + /// - Throws: When parsing the root fails. + private func parsedRootAsPrimitive() throws -> URIParsedValue? { + try cachedRoot(as: \.primitive, parse: { try $0.parseRootAsPrimitive(rootKey: rootKey)?.value }) + } + + /// Parse the root as an array. + /// - Returns: The parsed value. + /// - Throws: When parsing the root fails. + private func parsedRootAsArray() throws -> [URIParsedValue] { + try cachedRoot(as: \.array, parse: { try $0.parseRootAsArray(rootKey: rootKey).map(\.value) }) + } + + /// Parse the root as a dictionary. + /// - Returns: The parsed value. + /// - Throws: When parsing the root fails. + private func parsedRootAsDictionary() throws -> [URIParsedKeyComponent: [URIParsedValue]] { + try cachedRoot( + as: \.dictionary, + parse: { parser in + func normalizedDictionaryKey(_ key: URIParsedKey) throws -> Substring { + func validateComponentCount(_ count: Int) throws { + guard key.components.count == count else { + try throwMismatch( + "Decoding a dictionary key encountered an unexpected number of components (expected: \(count), got: \(key.components.count)." + ) + } + } + switch (configuration.style, configuration.explode) { + case (.form, true), (.simple, _): + try validateComponentCount(1) + return key.components[0] + case (.form, false), (.deepObject, true): + try validateComponentCount(2) + return key.components[1] + case (.deepObject, false): try throwMismatch("Decoding deepObject + unexplode is not supported.") + } + } + let tuples = try parser.parseRootAsDictionary(rootKey: rootKey) + let normalizedTuples: [(Substring, [URIParsedValue])] = try tuples.map { pair in + try (normalizedDictionaryKey(pair.key), [pair.value]) + } + return Dictionary(normalizedTuples, uniquingKeysWith: +) } - return values - } - let values = try nodeAsArray(node) - if values == [""] && style == .simple { - // An unexploded simple combination produces a ["":[""]] for an - // empty string. It should be parsed as an empty dictionary. - return ["": [""]] - } - guard values.count % 2 == 0 else { - try throwMismatch("Cannot parse an unexploded dictionary an odd number of elements.") - } - let pairs = stride(from: values.startIndex, to: values.endIndex, by: 2) - .map { firstIndex in (values[firstIndex], [values[firstIndex + 1]]) } - let convertedNode = Dictionary(pairs, uniquingKeysWith: { $0 + $1 }) - return convertedNode + ) } - /// Extracts the node at the top of the coding stack and tries to treat it - /// as an array. - /// - Returns: The value if it can be treated as an array. - /// - Throws: An error if the node cannot be treated as an array. - private func currentElementAsArray() throws -> URIParsedValueArray { try nodeAsArray(currentElement) } - - /// Checks if the provided node can be treated as an array, and returns - /// it if so. - /// - Parameter node: The node to check. - /// - Returns: The value if it can be treated as an array. - /// - Throws: An error if the node cannot be treated as a valid array. - private func nodeAsArray(_ node: URIDecodedNode) throws -> URIParsedValueArray { - switch node { - case .single(let value): return [value] - case .array(let values): return values - case .dictionary(let values): return try rootValue(in: values) + // MARK: - decoding utilities + + /// Returns a dictionary value. + /// - Parameters: + /// - key: The key for which to return the value. + /// - dictionary: The dictionary in which to find the value. + /// - Returns: The value in the dictionary, or nil if not found. + /// - Throws: When multiple values are found for the key. + func primitiveValue(forKey key: String, in dictionary: [URIParsedKeyComponent: [URIParsedValue]]) throws + -> URIParsedValue? + { + let values = dictionary[key[...], default: []] + if values.isEmpty { return nil } + if values.count > 1 { try throwMismatch("Dictionary value contains multiple values.") } + return values[0] + } + + // MARK: - withCurrent methods + + /// Use the current top of the stack as a primitive value. + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form decoder (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + /// - Parameter work: The closure in which to use the value. + /// - Returns: Any value returned from the closure. + /// - Throws: When parsing the root fails. + private func withCurrentPrimitiveElement(_ work: (URIParsedValue?) throws -> R) throws -> R { + if !codingStack.isEmpty { + // Nesting is involved. + // There are exactly three scenarios we support: + // - primitive in a top level array + // - primitive in a top level dictionary + // - primitive in a nested array inside a top level dictionary + if codingStack.count == 1 { + let key = codingStack[0] + if let intKey = key.intValue { + // Top level array. + return try work(parsedRootAsArray()[intKey]) + } else { + // Top level dictionary. + return try work(primitiveValue(forKey: key.stringValue, in: parsedRootAsDictionary())) + } + } else if codingStack.count == 2 { + // Nested array within a top level dictionary. + let dictionaryKey = codingStack[0].stringValue[...] + guard let nestedArrayKey = codingStack[1].intValue else { + try throwMismatch("Nested coding key is not an integer, hinting at unsupported nesting.") + } + return try work(parsedRootAsDictionary()[dictionaryKey, default: []][nestedArrayKey]) + } else { + try throwMismatch("Arbitrary nesting of containers is not supported.") + } + } else { + // Top level primitive. + return try work(parsedRootAsPrimitive()) } } - /// Extracts the node at the top of the coding stack and tries to treat it - /// as a primitive value. - /// - Returns: The value if it can be treated as a primitive value. - /// - Throws: An error if the node cannot be treated as a primitive value. - func currentElementAsSingleValue() throws -> URIParsedValue { try nodeAsSingleValue(currentElement) } - - /// Checks if the provided node can be treated as a primitive value, and - /// returns it if so. - /// - Parameter node: The node to check. - /// - Returns: The value if it can be treated as a primitive value. - /// - Throws: An error if the node cannot be treated as a primitive value. - private func nodeAsSingleValue(_ node: URIDecodedNode) throws -> URIParsedValue { - // A single value can be parsed from a node that: - // 1. Has a single key-value pair - // 2. The value array has a single element. - let array: URIParsedValueArray - switch node { - case .single(let value): return value - case .array(let values): array = values - case .dictionary(let values): array = try rootValue(in: values) + /// Use the current top of the stack as an array. + /// - Parameter work: The closure in which to use the value. + /// - Returns: Any value returned from the closure. + /// - Throws: When parsing the root fails. + private func withCurrentArrayElements(_ work: ([URIParsedValue]) throws -> R) throws -> R { + if let nestedArrayParentKey = codingStack.first { + // Top level is dictionary, first level nesting is array. + // Get all the elements that match this rootKey + nested key path. + return try work(parsedRootAsDictionary()[nestedArrayParentKey.stringValue[...]] ?? []) + } else { + // Top level array. + return try work(parsedRootAsArray()) } - guard array.count == 1 else { - if style == .simple { return Substring(array.joined(separator: ",")) } - let reason = array.isEmpty ? "an empty node" : "a node with multiple values" - try throwMismatch("Cannot parse a value from \(reason).") + } + + /// Use the current top of the stack as a dictionary. + /// - Parameter work: The closure in which to use the value. + /// - Returns: Any value returned from the closure. + /// - Throws: When parsing the root fails or if there is unsupported extra nesting of containers. + private func withCurrentDictionaryElements(_ work: ([URIParsedKeyComponent: [URIParsedValue]]) throws -> R) + throws -> R + { + if !codingStack.isEmpty { + try throwMismatch("Nesting a dictionary inside another container is not supported.") + } else { + // Top level dictionary. + return try work(parsedRootAsDictionary()) } - let value = array[0] - return value } - /// Returns the nested value at the provided index inside the node at the - /// top of the coding stack. - /// - Parameter index: The index of the nested value. - /// - Returns: The nested value. - /// - Throws: An error if the current node is not a valid array, or if the - /// index is out of bounds. - private func nestedValueInCurrentElementAsArray(at index: Int) throws -> URIParsedValue { - let values = try currentElementAsArray() - guard index < values.count else { throw GeneralError.codingKeyOutOfBounds } - return values[index] + // MARK: - metadata and data accessors + + /// Returns the current top-of-stack as a primitive value. + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form decoder (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + /// - Returns: The primitive value, or nil if not found. + /// - Throws: When parsing the root fails. + func currentElementAsSingleValue() throws -> URIParsedValue? { try withCurrentPrimitiveElement { $0 } } + + /// Returns the count of elements in the current top-of-stack array. + /// - Returns: The number of elements. + /// - Throws: When parsing the root fails. + func countOfCurrentArray() throws -> Int { try withCurrentArrayElements { $0.count } } + + /// Returns an element from the current top-of-stack array. + /// - Parameter index: The position in the array to return. + /// - Returns: The primitive value from the array. + /// - Throws: When parsing the root fails. + func nestedElementInCurrentArray(atIndex index: Int) throws -> URIParsedValue { + try withCurrentArrayElements { $0[index] } } - /// Returns the nested value at the provided key inside the node at the - /// top of the coding stack. - /// - Parameter key: The key of the nested value. - /// - Returns: The nested value. - /// - Throws: An error if the current node is not a valid dictionary, or - /// if no value exists for the key. - private func nestedValuesInCurrentElementAsDictionary(forKey key: String) throws -> URIParsedValueArray { - let values = try currentElementAsDictionary() - guard let value = values[key[...]] else { throw GeneralError.codingKeyNotFound } - return value + /// Returns an element from the current top-of-stack dictionary. + /// - Parameter key: The key to find a value for. + /// - Returns: The value for the key, or nil if not found. + /// - Throws: When parsing the root fails. + func nestedElementInCurrentDictionary(forKey key: String) throws -> URIParsedValue? { + try withCurrentDictionaryElements { dictionary in try primitiveValue(forKey: key, in: dictionary) } + } + + /// Returns a Boolean value that represents whether the current top-of-stack dictionary + /// contains a value for the provided key. + /// - Parameter key: The key for which to look for a value. + /// - Returns: `true` if a value was found, `false` otherwise. + func containsElementInCurrentDictionary(forKey key: String) -> Bool { + (try? withCurrentDictionaryElements({ dictionary in dictionary[key[...]] != nil })) ?? false + } + + /// Returns a list of keys found in the current top-of-stack dictionary. + /// - Returns: A list of keys from the dictionary. + /// - Throws: When parsing the root fails. + func elementKeysInCurrentDictionary() -> [String] { + (try? withCurrentDictionaryElements { dictionary in dictionary.keys.map(String.init) }) ?? [] } } extension URIValueFromNodeDecoder: Decoder { - var codingPath: [any CodingKey] { codingStack.map(\.key) } + var codingPath: [any CodingKey] { codingStack } var userInfo: [CodingUserInfoKey: Any] { [:] } func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key: CodingKey { - let values = try currentElementAsDictionary() - return .init(URIKeyedDecodingContainer(decoder: self, values: values)) + KeyedDecodingContainer(URIKeyedDecodingContainer(decoder: self)) } - func unkeyedContainer() throws -> any UnkeyedDecodingContainer { - let values = try currentElementAsArray() - return URIUnkeyedDecodingContainer(decoder: self, values: values) - } + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { URIUnkeyedDecodingContainer(decoder: self) } func singleValueContainer() throws -> any SingleValueDecodingContainer { URISingleValueDecodingContainer(decoder: self) diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift index 21028207..06103408 100644 --- a/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift @@ -15,7 +15,7 @@ import Foundation /// A type that encodes an `Encodable` value to an URI-encoded string -/// using the rules from RFC 6570, RFC 1866, and OpenAPI 3.0.3, depending on +/// using the rules from RFC 6570, RFC 1866, and OpenAPI 3.0.4, depending on /// the configuration. /// /// [RFC 6570 - Form-style query expansion.](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8) @@ -45,6 +45,13 @@ import Foundation /// | `{list\*}` | `red,green,blue` | /// | `{keys}` | `semi,%3B,dot,.,comma,%2C` | /// | `{keys\*}` | `semi=%3B,dot=.,comma=%2C` | +/// +/// [OpenAPI 3.0.4 - Deep object expansion.](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.4.md#style-examples) +/// +/// | Example Template | Expansion | +/// | ---------------- | ----------------------------------------------------------| +/// | `{?keys\*}` | `?keys%5Bsemi%5D=%3B&keys%5Bdot%5D=.&keys%5Bcomma%5D=%2C` | +/// struct URIEncoder: Sendable { /// The serializer used to turn `URIEncodedNode` values to a string. diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift index 35f71884..5d892151 100644 --- a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift @@ -19,6 +19,13 @@ struct URIUnkeyedEncodingContainer { /// The associated encoder. let encoder: URIValueToNodeEncoder + + /// Creates a new encoder. + /// - Parameter encoder: The associated encoder. + init(encoder: URIValueToNodeEncoder) { + self.encoder = encoder + try? encoder.currentStackEntry.storage.markAsArray() + } } extension URIUnkeyedEncodingContainer { diff --git a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift index ff224621..9e1da427 100644 --- a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift +++ b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift @@ -14,24 +14,22 @@ import Foundation -/// A type that parses a `URIParsedNode` from a URI-encoded string. +/// A type that can parse a primitive, array, and a dictionary from a URI-encoded string. struct URIParser: Sendable { - /// The configuration instructing the parser how to interpret the raw - /// string. + /// The configuration of the parser. private let configuration: URICoderConfiguration - /// The underlying raw string storage. - private var data: Raw + /// The string to parse. + private let data: Raw /// Creates a new parser. /// - Parameters: - /// - configuration: The configuration instructing the parser how - /// to interpret the raw string. + /// - configuration: The configuration of the parser. /// - data: The string to parse. init(configuration: URICoderConfiguration, data: Substring) { self.configuration = configuration - self.data = data[...] + self.data = data } } @@ -43,6 +41,7 @@ enum ParsingError: Swift.Error, Hashable { /// A malformed key-value pair was detected. case malformedKeyValuePair(Raw) + /// An invalid configuration was detected. case invalidConfiguration(String) } @@ -50,198 +49,268 @@ enum ParsingError: Swift.Error, Hashable { // MARK: - Parser implementations extension URIParser { - - /// Parses the root node from the underlying string, selecting the logic - /// based on the configuration. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - mutating func parseRoot() throws -> URIParsedNode { - // A completely empty string should get parsed as a single - // empty key with a single element array with an empty string - // if the style is simple, otherwise it's an empty dictionary. - if data.isEmpty { - switch configuration.style { - case .form: return [:] - case .simple: return ["": [""]] - case .deepObject: return [:] - } - } + /// Parses the string as a primitive value. + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form parser (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + /// - Parameter rootKey: The key of the root object, used to filter out unrelated values. + /// - Returns: The parsed primitive value, or nil if not found. + /// - Throws: When parsing the root fails. + func parseRootAsPrimitive(rootKey: URIParsedKeyComponent) throws -> URIParsedPair? { + var data = data 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 (.form, _): + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + while !data.isEmpty { + let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( + first: keyValueSeparator, + second: pairSeparator + ) + switch firstResult { + case .foundFirst: + let unescapedKey = unescapeValue(firstValue) + if unescapedKey == rootKey { + let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) + let key = URIParsedKey([unescapedKey]) + return .init(key: key, value: unescapeValue(secondValue)) + } else { + // Ignore the value, skip to the end of the pair. + _ = data.parseUpToCharacterOrEnd(pairSeparator) + } + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) + } + } + return nil + case (.simple, _): return .init(key: .empty, value: unescapeValue(data)) + case (.deepObject, true): + throw ParsingError.invalidConfiguration("deepObject does not support primitive values, only dictionaries") case (.deepObject, false): - let reason = "Deep object style is only valid with explode set to true" - throw ParsingError.invalidConfiguration(reason) + throw ParsingError.invalidConfiguration("deepObject + explode: false is not supported") } } - /// Parses the root node assuming the raw string uses the form style - /// and the explode parameter is enabled. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - private mutating func parseExplodedFormRoot() throws -> URIParsedNode { - try parseGenericRoot { data, appendPair in + /// Parses the string as an array. + /// - Parameter rootKey: The key of the root object, used to filter out unrelated values. + /// - Returns: The parsed array. + /// - Throws: When parsing the root fails. + func parseRootAsArray(rootKey: URIParsedKeyComponent) throws -> [URIParsedPair] { + var data = data + switch (configuration.style, configuration.explode) { + case (.form, true): let keyValueSeparator: Character = "=" let pairSeparator: Character = "&" - + var items: [URIParsedPair] = [] while !data.isEmpty { let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( first: keyValueSeparator, second: pairSeparator ) - let key: Raw - let value: Raw switch firstResult { case .foundFirst: - // Hit the key/value separator, so a value will follow. - let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) - key = firstValue - value = secondValue - case .foundSecondOrEnd: - // No key/value separator, treat the string as the key. - key = firstValue - value = .init() + let unescapedKey = unescapeValue(firstValue) + if unescapedKey == rootKey { + let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) + let key = URIParsedKey([unescapedKey]) + items.append(.init(key: key, value: unescapeValue(secondValue))) + } else { + // Ignore the value, skip to the end of the pair. + _ = data.parseUpToCharacterOrEnd(pairSeparator) + } + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) } - appendPair(key, [value]) } - } - } - - /// Parses the root node assuming the raw string uses the form style - /// and the explode parameter is disabled. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - private mutating func parseUnexplodedFormRoot() throws -> URIParsedNode { - try parseGenericRoot { data, appendPair in + return items + case (.form, false): let keyValueSeparator: Character = "=" let pairSeparator: Character = "&" - let valueSeparator: Character = "," - + let arrayElementSeparator: Character = "," + var items: [URIParsedPair] = [] while !data.isEmpty { let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( first: keyValueSeparator, second: pairSeparator ) - let key: Raw - let values: [Raw] switch firstResult { case .foundFirst: - // Hit the key/value separator, so one or more values will follow. - var accumulatedValues: [Raw] = [] - valueLoop: while !data.isEmpty { - let (secondResult, secondValue) = data.parseUpToEitherCharacterOrEnd( - first: valueSeparator, - second: pairSeparator - ) - accumulatedValues.append(secondValue) - switch secondResult { - case .foundFirst: - // Hit the value separator, so ended one value and - // another one is coming. - continue - case .foundSecondOrEnd: - // Hit the pair separator or the end, this is the - // last value. - break valueLoop + let unescapedKey = unescapeValue(firstValue) + if unescapedKey == rootKey { + let key = URIParsedKey([unescapedKey]) + elementScan: while !data.isEmpty { + let (secondResult, secondValue) = data.parseUpToEitherCharacterOrEnd( + first: arrayElementSeparator, + second: pairSeparator + ) + items.append(.init(key: key, value: unescapeValue(secondValue))) + switch secondResult { + case .foundFirst: continue elementScan + case .foundSecondOrEnd: break elementScan + } } + } else { + // Ignore the value, skip to the end of the pair. + _ = data.parseUpToCharacterOrEnd(pairSeparator) } - if accumulatedValues.isEmpty { - // We hit the key/value separator, so always write - // at least one empty value. - accumulatedValues.append("") - } - key = firstValue - values = accumulatedValues case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) } - appendPair(key, values) } + return items + case (.simple, _): + let pairSeparator: Character = "," + var items: [URIParsedPair] = [] + while !data.isEmpty { + let value = data.parseUpToCharacterOrEnd(pairSeparator) + items.append(.init(key: .empty, value: unescapeValue(value))) + } + return items + case (.deepObject, true): + throw ParsingError.invalidConfiguration("deepObject does not support array values, only dictionaries") + case (.deepObject, false): + throw ParsingError.invalidConfiguration("deepObject + explode: false is not supported") } } - /// Parses the root node assuming the raw string uses the simple style - /// and the explode parameter is enabled. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - private mutating func parseExplodedSimpleRoot() throws -> URIParsedNode { - try parseGenericRoot { data, appendPair in + /// Parses the string as a dictionary. + /// - Parameter rootKey: The key of the root object, used to filter out unrelated values. + /// - Returns: The parsed key/value pairs as an array. + /// - Throws: When parsing the root fails. + func parseRootAsDictionary(rootKey: URIParsedKeyComponent) throws -> [URIParsedPair] { + var data = data + switch (configuration.style, configuration.explode) { + case (.form, true): + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + var items: [URIParsedPair] = [] + while !data.isEmpty { + let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( + first: keyValueSeparator, + second: pairSeparator + ) + switch firstResult { + case .foundFirst: + let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) + let key = URIParsedKey([unescapeValue(firstValue)]) + items.append(.init(key: key, value: unescapeValue(secondValue))) + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) + } + } + return items + case (.form, false): + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + let arrayElementSeparator: Character = "," + var items: [URIParsedPair] = [] + while !data.isEmpty { + let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( + first: keyValueSeparator, + second: pairSeparator + ) + switch firstResult { + case .foundFirst: + let unescapedKey = unescapeValue(firstValue) + if unescapedKey == rootKey { + elementScan: while !data.isEmpty { + let (innerKeyResult, innerKeyValue) = data.parseUpToEitherCharacterOrEnd( + first: arrayElementSeparator, + second: pairSeparator + ) + switch innerKeyResult { + case .foundFirst: + let (innerValueResult, innerValueValue) = data.parseUpToEitherCharacterOrEnd( + first: arrayElementSeparator, + second: pairSeparator + ) + items.append( + .init( + key: URIParsedKey([unescapedKey, innerKeyValue]), + value: unescapeValue(innerValueValue) + ) + ) + switch innerValueResult { + case .foundFirst: continue elementScan + case .foundSecondOrEnd: break elementScan + } + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(innerKeyValue) + } + } + } else { + // Ignore the value, skip to the end of the pair. + _ = data.parseUpToCharacterOrEnd(pairSeparator) + } + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) + } + } + return items + case (.simple, true): let keyValueSeparator: Character = "=" let pairSeparator: Character = "," - + var items: [URIParsedPair] = [] while !data.isEmpty { let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( first: keyValueSeparator, second: pairSeparator ) - let key: Raw - let value: Raw + let key: URIParsedKey + let value: URIParsedValue switch firstResult { case .foundFirst: - // Hit the key/value separator, so a value will follow. let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) - key = firstValue + key = URIParsedKey([unescapeValue(firstValue)]) value = secondValue - case .foundSecondOrEnd: - // No key/value separator, treat the string as the value. - key = .init() - value = firstValue + items.append(.init(key: key, value: unescapeValue(value))) + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) } - appendPair(key, [value]) } - } - } - - /// Parses the root node assuming the raw string uses the simple style - /// and the explode parameter is disabled. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - private mutating func parseUnexplodedSimpleRoot() throws -> URIParsedNode { - // Unexploded simple dictionary cannot be told apart from - // an array, so we just accumulate all pairs as standalone - // values and add them to the array. It'll be the higher - // level decoder's responsibility to parse this properly. - - try parseGenericRoot { data, appendPair in + return items + case (.simple, false): let pairSeparator: Character = "," + var items: [URIParsedPair] = [] while !data.isEmpty { - let value = data.parseUpToCharacterOrEnd(pairSeparator) - appendPair(.init(), [value]) + let rawKey = data.parseUpToCharacterOrEnd(pairSeparator) + let value: URIParsedValue + if data.isEmpty { value = "" } else { value = data.parseUpToCharacterOrEnd(pairSeparator) } + let key = URIParsedKey([unescapeValue(rawKey)]) + items.append(.init(key: key, value: unescapeValue(value))) } - } - } - /// 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 + return items + case (.deepObject, true): 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 - } + let nestedKeyStart: Character = "[" + let nestedKeyEnd: Character = "]" + var items: [URIParsedPair] = [] 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]) + switch firstResult { + case .foundFirst: + var unescapedComposedKey = unescapeValue(firstValue) + if unescapedComposedKey.contains("[") && unescapedComposedKey.contains("]") { + // Do a quick check whether this is even a deepObject-encoded key, as + // we need to safely skip any unrelated keys, which might be formatted + // some other way. + let parentParsedKey = unescapedComposedKey.parseUpToCharacterOrEnd(nestedKeyStart) + let childParsedKey = unescapedComposedKey.parseUpToCharacterOrEnd(nestedKeyEnd) + if parentParsedKey == rootKey { + let key = URIParsedKey([parentParsedKey, childParsedKey]) + let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) + items.append(.init(key: key, value: unescapeValue(secondValue))) + continue + } + } + // Ignore the value, skip to the end of the pair. + _ = data.parseUpToCharacterOrEnd(pairSeparator) + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) + } } + return items + case (.deepObject, false): + throw ParsingError.invalidConfiguration("deepObject + explode: false is not supported") } - return parseNode } } @@ -249,24 +318,6 @@ extension URIParser { extension URIParser { - /// Parses the underlying string using a parser closure. - /// - Parameter parser: A closure that accepts another closure, which should - /// be called 0 or more times, once for each parsed key-value pair. - /// - Returns: The accumulated node. - /// - Throws: An error if parsing using the provided parser closure fails, - private mutating func parseGenericRoot(_ parser: (inout Raw, (Raw, [Raw]) -> Void) throws -> Void) throws - -> URIParsedNode - { - var root = URIParsedNode() - let spaceEscapingCharacter = configuration.spaceEscapingCharacter - let unescapeValue: (Raw) -> Raw = { Self.unescapeValue($0, spaceEscapingCharacter: spaceEscapingCharacter) } - try parser(&data) { key, values in - let newItem = [unescapeValue(key): values.map(unescapeValue)] - root.merge(newItem) { $0 + $1 } - } - return root - } - /// Removes escaping from the provided string. /// - Parameter escapedValue: An escaped string. /// - Returns: The provided string with escaping removed. diff --git a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift index e7817720..838ca9b1 100644 --- a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift +++ b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift @@ -205,7 +205,6 @@ extension URISerializer { /// style and explode parameters in the configuration). /// - Throws: An error if serialization of the array fails. private mutating func serializeArray(_ array: [URIEncodedNode.Primitive], forKey key: String) throws { - guard !array.isEmpty else { return } let keyAndValueSeparator: String? let pairSeparator: String switch (configuration.style, configuration.explode) { @@ -220,6 +219,7 @@ extension URISerializer { pairSeparator = "," case (.deepObject, _): throw SerializationError.deepObjectsArrayNotSupported } + guard !array.isEmpty else { return } func serializeNext(_ element: URIEncodedNode.Primitive) throws { if let keyAndValueSeparator { try serializePrimitiveKeyValuePair(element, forKey: key, separator: keyAndValueSeparator) diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index 9e16fbfb..3d956bb2 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -177,6 +177,30 @@ final class Test_ServerConverterExtensions: Test_Runtime { XCTAssertEqual(value, ["foo", "bar"]) } + func test_getOptionalQueryItemAsURI_deepObject_exploded() throws { + let query: Substring = "sort%5Bid%5D=ascending&sort%5Bname%5D=descending&unrelated=foo" + let value: [String: String]? = try converter.getOptionalQueryItemAsURI( + in: query, + style: .deepObject, + explode: true, + name: "sort", + as: [String: String].self + ) + XCTAssertEqual(value, ["id": "ascending", "name": "descending"]) + } + + func test_getOptionalQueryItemAsURI_deepObject_exploded_empty() throws { + let query: Substring = "foo=bar" + let value: [String: String]? = try converter.getOptionalQueryItemAsURI( + in: query, + style: .deepObject, + explode: true, + name: "sort", + as: [String: String].self + ) + XCTAssertNil(value) + } + func test_getRequiredQueryItemAsURI_arrayOfStrings() throws { let query: Substring = "search=foo&search=bar" let value: [String] = try converter.getRequiredQueryItemAsURI( @@ -201,6 +225,18 @@ final class Test_ServerConverterExtensions: Test_Runtime { XCTAssertEqual(value, ["foo", "bar"]) } + func test_getRequiredQueryItemAsURI_deepObject_exploded() throws { + let query: Substring = "sort%5Bid%5D=ascending&unrelated=foo&sort%5Bname%5D=descending" + let value: [String: String] = try converter.getRequiredQueryItemAsURI( + in: query, + style: .deepObject, + explode: true, + name: "sort", + as: [String: String].self + ) + XCTAssertEqual(value, ["id": "ascending", "name": "descending"]) + } + func test_getOptionalQueryItemAsURI_date() throws { let query: Substring = "search=\(testDateEscapedString)" let value: Date? = try converter.getOptionalQueryItemAsURI( diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift index c02c83c3..0e611789 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift @@ -16,11 +16,76 @@ import XCTest final class Test_URIDecoder: Test_Runtime { - func testDecoding() throws { + func testDecoding_string() throws { + _test( + "hello world", + forKey: "message", + from: .init( + formExplode: "message=hello%20world", + formUnexplode: "message=hello%20world", + simpleExplode: "hello%20world", + simpleUnexplode: "hello%20world", + formDataExplode: "message=hello+world", + formDataUnexplode: "message=hello+world", + deepObjectExplode: nil + ) + ) + } + + func testDecoding_maxNesting() throws { + struct Filter: Decodable, Equatable { + enum State: String, Decodable, Equatable { + case enabled + case disabled + } + var state: [State] + } + _test( + Filter(state: [.enabled, .disabled]), + forKey: "filter", + from: .init( + formExplode: "state=enabled&state=disabled", + formUnexplode: "filter=state,enabled,state,disabled", + simpleExplode: "state=enabled,state=disabled", + simpleUnexplode: "state,enabled,state,disabled", + formDataExplode: "state=enabled&state=disabled", + formDataUnexplode: "filter=state,enabled,state,disabled", + deepObjectExplode: "filter%5Bstate%5D=enabled&filter%5Bstate%5D=disabled" + ) + ) + } + + func testDecoding_array() throws { + _test( + ["hello world", "goodbye world"], + forKey: "message", + from: .init( + formExplode: "message=hello%20world&message=goodbye%20world", + formUnexplode: "message=hello%20world,goodbye%20world", + simpleExplode: "hello%20world,goodbye%20world", + simpleUnexplode: "hello%20world,goodbye%20world", + formDataExplode: "message=hello+world&message=goodbye+world", + formDataUnexplode: "message=hello+world,goodbye+world", + deepObjectExplode: nil + ) + ) + } + + func testDecoding_struct() throws { struct Foo: Decodable, Equatable { var bar: String } - let decoder = URIDecoder(configuration: .formDataExplode) - let decodedValue = try decoder.decode(Foo.self, forKey: "", from: "bar=hello+world") - XCTAssertEqual(decodedValue, Foo(bar: "hello world")) + _test( + Foo(bar: "hello world"), + forKey: "message", + from: .init( + formExplode: "bar=hello%20world", + formUnexplode: "message=bar,hello%20world", + simpleExplode: "bar=hello%20world", + simpleUnexplode: "bar,hello%20world", + formDataExplode: "bar=hello+world", + formDataUnexplode: "message=bar,hello+world", + deepObjectExplode: "message%5Bbar%5D=hello%20world" + ) + ) } func testDecoding_structWithOptionalProperty() throws { @@ -39,6 +104,21 @@ final class Test_URIDecoder: Test_Runtime { } } + func testDecoding_freeformObject() throws { + let decoder = URIDecoder(configuration: .formDataExplode) + do { + let decodedValue = try decoder.decode( + OpenAPIObjectContainer.self, + forKey: "", + from: "baz=1&bar=hello+world&bar=goodbye+world" + ) + XCTAssertEqual( + decodedValue, + try .init(unvalidatedValue: ["bar": ["hello world", "goodbye world"], "baz": 1]) + ) + } + } + func testDecoding_rootValue() throws { let decoder = URIDecoder(configuration: .formDataExplode) do { @@ -73,3 +153,53 @@ final class Test_URIDecoder: Test_Runtime { } } } + +extension Test_URIDecoder { + + struct Inputs { + var formExplode: Substring? + var formUnexplode: Substring? + var simpleExplode: Substring? + var simpleUnexplode: Substring? + var formDataExplode: Substring? + var formDataUnexplode: Substring? + var deepObjectExplode: Substring? + } + + func _test( + _ value: T, + forKey key: String, + from inputs: Inputs, + file: StaticString = #file, + line: UInt = #line + ) { + func _run(name: String, configuration: URICoderConfiguration, sourceString: Substring) { + let decoder = URIDecoder(configuration: configuration) + do { + let decodedValue = try decoder.decode(T.self, forKey: key, from: sourceString) + XCTAssertEqual(decodedValue, value, "Failed in \(name)", file: file, line: line) + } catch { XCTFail("Threw an error in \(name): \(error)", file: file, line: line) } + } + if let value = inputs.formExplode { + _run(name: "formExplode", configuration: .formExplode, sourceString: value) + } + if let value = inputs.formUnexplode { + _run(name: "formUnexplode", configuration: .formUnexplode, sourceString: value) + } + if let value = inputs.simpleExplode { + _run(name: "simpleExplode", configuration: .simpleExplode, sourceString: value) + } + if let value = inputs.simpleUnexplode { + _run(name: "simpleUnexplode", configuration: .simpleUnexplode, sourceString: value) + } + if let value = inputs.formDataExplode { + _run(name: "formDataExplode", configuration: .formDataExplode, sourceString: value) + } + if let value = inputs.formDataUnexplode { + _run(name: "formDataUnexplode", configuration: .formDataUnexplode, sourceString: value) + } + if let value = inputs.deepObjectExplode { + _run(name: "deepObjectExplode", configuration: .deepObjectExplode, sourceString: value) + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift index f1236cb9..6ce7037b 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift @@ -36,60 +36,56 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime { } // An empty string. - try test(["root": [""]], "", key: "root") + try test("root=", "", key: "root") // An empty string with a simple style. - try test(["root": [""]], "", key: "root", style: .simple) + try test("", "", key: "root", style: .simple) // A string with a space. - try test(["root": ["Hello World"]], "Hello World", key: "root") + try test("root=Hello%20world", "Hello world", key: "root") // An enum. - try test(["root": ["red"]], SimpleEnum.red, key: "root") + try test("root=red", SimpleEnum.red, key: "root") // An integer. - try test(["root": ["1234"]], 1234, key: "root") + try test("root=1234", 1234, key: "root") // A float. - try test(["root": ["12.34"]], 12.34, key: "root") + try test("root=12.34", 12.34, key: "root") // A bool. - try test(["root": ["true"]], true, key: "root") + try test("root=true", true, key: "root") // A simple array of strings. - try test(["root": ["a", "b", "c"]], ["a", "b", "c"], key: "root") + try test("root=a&root=b&root=c", ["a", "b", "c"], key: "root") // A simple array of enums. - try test(["root": ["red", "green", "blue"]], [.red, .green, .blue] as [SimpleEnum], key: "root") + try test("root=red&root=green&root=blue", [.red, .green, .blue] as [SimpleEnum], key: "root") // A struct. - try test(["foo": ["bar"]], SimpleStruct(foo: "bar"), key: "root") + 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"]], + "foo=bar&bar=1&bar=2&val=baz&val=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") + try test("foo=bar&color=blue", SimpleStruct(foo: "bar", color: .blue), key: "root") // A simple dictionary. - try test(["one": ["1"], "two": ["2"]], ["one": 1, "two": 2], key: "root") + try test("one=1&two=2", ["one": 1, "two": 2], key: "root") // A unexploded simple dictionary. - try test(["root": ["one", "1", "two", "2"]], ["one": 1, "two": 2], key: "root", explode: false) + try test("one,1,two,2", ["one": 1, "two": 2], key: "root", style: .simple, explode: false) // A dictionary of enums. - try test( - ["one": ["blue"], "two": ["green"]], - ["one": .blue, "two": .green] as [String: SimpleEnum], - key: "root" - ) + try test("one=blue&two=green", ["one": .blue, "two": .green] as [String: SimpleEnum], key: "root") func test( - _ node: URIParsedNode, + _ data: String, _ expectedValue: T, key: String, style: URICoderConfiguration.Style = .form, @@ -98,11 +94,14 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime { line: UInt = #line ) throws { let decoder = URIValueFromNodeDecoder( - node: node, + data: data[...], rootKey: key[...], - style: style, - explode: explode, - dateTranscoder: .iso8601 + configuration: .init( + style: style, + explode: explode, + spaceEscapingCharacter: .percentEncoded, + dateTranscoder: .iso8601 + ) ) let decodedValue = try decoder.decodeRoot(T.self) XCTAssertEqual(decodedValue, expectedValue, file: file, line: line) diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift index 16a6e02d..a94805ac 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift @@ -14,137 +14,495 @@ import XCTest @testable import OpenAPIRuntime +/// Tests for URIParser. +/// +/// Guiding examples: +/// +/// rootKey: "color" +/// +/// form explode: +/// - nil: "" -> nil +/// - empty: "color=" -> ("color/0", "") +/// - primitive: "color=blue" -> ("color/0", "blue") +/// - array: "color=blue&color=black&color=brown" -> [("color/0", "blue"), ("color/1", "black"), ("color/2", "brown)] +/// - dictionary+array: "R=100&G=200&G=150" -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] +/// +/// form unexplode: +/// - nil: "" -> nil +/// - empty: "color=" -> ("color/0", "") +/// - primitive: "color=blue" -> ("color/0", "blue") +/// - array: "color=blue,black,brown" -> [("color/0", "blue"), ("color/1", "black"), ("color/2", "brown)] +/// - dictionary: "color=R,100,G,200,G,150" -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] +/// +/// simple explode: +/// - nil: "" -> ("color/0", "") +/// - empty: "" -> ("color/0", "") +/// - primitive: "blue" -> ("color/0", "blue") +/// - array: "blue,black,brown" -> [("color/0", "blue"), ("color/1", "black"), ("color/2", "brown)] +/// - dictionary+array: "R=100,G=200,G=150" -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] +/// +/// simple unexplode: +/// - nil: "" -> ("color/0", "") +/// - empty: "" -> ("color/0", "") +/// - primitive: "blue" -> ("color/0", "blue") +/// - array: "blue,black,brown" -> [("color/0", "blue"), ("color/1", "black"), ("color/2", "brown)] +/// - dictionary: "R,100,G,200,G,150" -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] +/// +/// deepObject unexplode: unsupported +/// +/// deepObject explode: +/// - nil: -> unsupported +/// - empty: -> unsupported +/// - primitive: -> unsupported +/// - array: -> unsupported +/// - dictionary: "color%5BR%5D=100&color%5BG%5D=200&color%5BG%5D=150" +/// -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] final class Test_URIParser: Test_Runtime { - let testedVariants: [URICoderConfiguration] = [ - .formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode, - .deepObjectExplode, - ] - func testParsing() throws { - let cases: [Case] = [ - makeCase( - .init( - formExplode: "empty=", - formUnexplode: "empty=", - simpleExplode: .custom("", value: ["": [""]]), - simpleUnexplode: .custom("", value: ["": [""]]), - formDataExplode: "empty=", - formDataUnexplode: "empty=", - deepObjectExplode: "object%5Bempty%5D=" - ), - value: ["empty": [""]] - ), - makeCase( - .init( - formExplode: "", - formUnexplode: "", - simpleExplode: .custom("", value: ["": [""]]), - simpleUnexplode: .custom("", value: ["": [""]]), - formDataExplode: "", - formDataUnexplode: "", - deepObjectExplode: "" - ), - value: [:] - ), - makeCase( - .init( - formExplode: "who=fred", - formUnexplode: "who=fred", - simpleExplode: .custom("fred", value: ["": ["fred"]]), - simpleUnexplode: .custom("fred", value: ["": ["fred"]]), - formDataExplode: "who=fred", - formDataUnexplode: "who=fred", - deepObjectExplode: "object%5Bwho%5D=fred" - ), - value: ["who": ["fred"]] - ), - makeCase( - .init( - formExplode: "hello=Hello%20World", - formUnexplode: "hello=Hello%20World", - simpleExplode: .custom("Hello%20World", value: ["": ["Hello World"]]), - simpleUnexplode: .custom("Hello%20World", value: ["": ["Hello World"]]), - formDataExplode: "hello=Hello+World", - formDataUnexplode: "hello=Hello+World", - deepObjectExplode: "object%5Bhello%5D=Hello%20World" - ), - value: ["hello": ["Hello World"]] - ), - makeCase( - .init( - formExplode: "list=red&list=green&list=blue", - formUnexplode: "list=red,green,blue", - 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", - deepObjectExplode: "object%5Blist%5D=red&object%5Blist%5D=green&object%5Blist%5D=blue" - ), - value: ["list": ["red", "green", "blue"]] - ), - makeCase( - .init( - formExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B", - formUnexplode: .custom( - "keys=comma,%2C,dot,.,list,one,list,two,semi,%3B", - value: ["keys": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]] - ), - simpleExplode: "comma=%2C,dot=.,list=one,list=two,semi=%3B", - simpleUnexplode: .custom( - "comma,%2C,dot,.,list,one,list,two,semi,%3B", - value: ["": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]] - ), - formDataExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B", - formDataUnexplode: .custom( - "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%5Blist%5D=one&keys%5Blist%5D=two&keys%5Bsemi%5D=%3B" - ), - value: ["semi": [";"], "dot": ["."], "comma": [","], "list": ["one", "two"]] - ), - ] - for testCase in cases { - func testVariant(_ variant: Case.Variant, _ input: Case.Variants.Input) throws { - var parser = URIParser(configuration: variant.config, data: input.string[...]) - 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) - try testVariant(.formUnexplode, variants.formUnexplode) - try testVariant(.simpleExplode, variants.simpleExplode) - try testVariant(.simpleUnexplode, variants.simpleUnexplode) - try testVariant(.formDataExplode, variants.formDataExplode) - try testVariant(.formDataUnexplode, variants.formDataUnexplode) - try testVariant(.deepObjectExplode, variants.deepObjectExplode) - } + // Guiding examples, test filtering relevant keys for the rootKey + try testCase( + formExplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=blue&suffix=baz", equals: .init(key: "color", value: "blue")), + array: .assert( + "prefix=bar&color=blue&color=black&color=brown&suffix=baz", + equals: [ + .init(key: "color", value: "blue"), .init(key: "color", value: "black"), + .init(key: "color", value: "brown"), + ] + ), + dictionary: .assert( + "R=100&G=200&G=150", + equals: [ + .init(key: "R", value: "100"), .init(key: "G", value: "200"), .init(key: "G", value: "150"), + ] + ) + ), + formUnexplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=blue&suffix=baz", equals: .init(key: "color", value: "blue")), + array: .assert( + "prefix=bar&color=blue,black,brown&suffix=baz", + equals: [ + .init(key: "color", value: "blue"), .init(key: "color", value: "black"), + .init(key: "color", value: "brown"), + ] + ), + dictionary: .assert( + "prefix=bar&color=R,100,G,200,G,150&suffix=baz", + equals: [ + .init(key: "color/R", value: "100"), .init(key: "color/G", value: "200"), + .init(key: "color/G", value: "150"), + ] + ) + ), + simpleExplode: .init( + rootKey: "color", + primitive: .assert("blue", equals: .init(key: .empty, value: "blue")), + array: .assert( + "blue,black,brown", + equals: [ + .init(key: .empty, value: "blue"), .init(key: .empty, value: "black"), + .init(key: .empty, value: "brown"), + ] + ), + dictionary: .assert( + "R=100,G=200,G=150", + equals: [ + .init(key: "R", value: "100"), .init(key: "G", value: "200"), .init(key: "G", value: "150"), + ] + ) + ), + simpleUnexplode: .init( + rootKey: "color", + primitive: .assert("blue", equals: .init(key: .empty, value: "blue")), + array: .assert( + "blue,black,brown", + equals: [ + .init(key: .empty, value: "blue"), .init(key: .empty, value: "black"), + .init(key: .empty, value: "brown"), + ] + ), + dictionary: .assert( + "R,100,G,200,G,150", + equals: [ + .init(key: "R", value: "100"), .init(key: "G", value: "200"), .init(key: "G", value: "150"), + ] + ) + ), + formDataExplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=blue&suffix=baz", equals: .init(key: "color", value: "blue")), + array: .assert( + "prefix=bar&color=blue&color=black&color=brown&suffix=baz", + equals: [ + .init(key: "color", value: "blue"), .init(key: "color", value: "black"), + .init(key: "color", value: "brown"), + ] + ), + dictionary: .assert( + "R=100&G=200&G=150", + equals: [ + .init(key: "R", value: "100"), .init(key: "G", value: "200"), .init(key: "G", value: "150"), + ] + ) + ), + formDataUnexplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=blue&suffix=baz", equals: .init(key: "color", value: "blue")), + array: .assert( + "prefix=bar&color=blue,black,brown&suffix=baz", + equals: [ + .init(key: "color", value: "blue"), .init(key: "color", value: "black"), + .init(key: "color", value: "brown"), + ] + ), + dictionary: .assert( + "prefix=bar&color=R,100,G,200,G,150&suffix=baz", + equals: [ + .init(key: "color/R", value: "100"), .init(key: "color/G", value: "200"), + .init(key: "color/G", value: "150"), + ] + ) + ), + deepObjectExplode: .init( + rootKey: "color", + primitive: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + array: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + dictionary: .assert( + "prefix%5Bfoo%5D=1&color%5BR%5D=100&color%5BG%5D=200&color%5BG%5D=150&suffix%5Bbaz%5D=2", + equals: [ + .init(key: "color/R", value: "100"), .init(key: "color/G", value: "200"), + .init(key: "color/G", value: "150"), + ] + ) + ) + ) + + // Test escaping + try testCase( + formExplode: .init( + rootKey: "message", + primitive: .assert("message=Hello%20world", equals: .init(key: "message", value: "Hello world")), + array: .assert( + "message=Hello%20world&message=%240", + equals: [.init(key: "message", value: "Hello world"), .init(key: "message", value: "$0")] + ), + dictionary: .assert( + "R=Hello%20world&G=%24%24%24&G=%40%40%40", + equals: [ + .init(key: "R", value: "Hello world"), .init(key: "G", value: "$$$"), + .init(key: "G", value: "@@@"), + ] + ) + ), + formUnexplode: .init( + rootKey: "message", + primitive: .assert("message=Hello%20world", equals: .init(key: "message", value: "Hello world")), + array: .assert( + "message=Hello%20world,%240", + equals: [.init(key: "message", value: "Hello world"), .init(key: "message", value: "$0")] + ), + dictionary: .assert( + "message=R,Hello%20world,G,%24%24%24,G,%40%40%40", + equals: [ + .init(key: "message/R", value: "Hello world"), .init(key: "message/G", value: "$$$"), + .init(key: "message/G", value: "@@@"), + ] + ) + ), + simpleExplode: .init( + rootKey: "message", + primitive: .assert("Hello%20world", equals: .init(key: .empty, value: "Hello world")), + array: .assert( + "Hello%20world,%24%24%24,%40%40%40", + equals: [ + .init(key: .empty, value: "Hello world"), .init(key: .empty, value: "$$$"), + .init(key: .empty, value: "@@@"), + ] + ), + dictionary: .assert( + "R=Hello%20world,G=%24%24%24,G=%40%40%40", + equals: [ + .init(key: "R", value: "Hello world"), .init(key: "G", value: "$$$"), + .init(key: "G", value: "@@@"), + ] + ) + ), + simpleUnexplode: .init( + rootKey: "message", + primitive: .assert("Hello%20world", equals: .init(key: .empty, value: "Hello world")), + array: .assert( + "Hello%20world,%24%24%24,%40%40%40", + equals: [ + .init(key: .empty, value: "Hello world"), .init(key: .empty, value: "$$$"), + .init(key: .empty, value: "@@@"), + ] + ), + dictionary: .assert( + "R,Hello%20world,G,%24%24%24,G,%40%40%40", + equals: [ + .init(key: "R", value: "Hello world"), .init(key: "G", value: "$$$"), + .init(key: "G", value: "@@@"), + ] + ) + ), + formDataExplode: .init( + rootKey: "message", + primitive: .assert("message=Hello+world", equals: .init(key: "message", value: "Hello world")), + array: .assert( + "message=Hello+world&message=%240", + equals: [.init(key: "message", value: "Hello world"), .init(key: "message", value: "$0")] + ), + dictionary: .assert( + "R=Hello+world&G=%24%24%24&G=%40%40%40", + equals: [ + .init(key: "R", value: "Hello world"), .init(key: "G", value: "$$$"), + .init(key: "G", value: "@@@"), + ] + ) + ), + formDataUnexplode: .init( + rootKey: "message", + primitive: .assert("message=Hello+world", equals: .init(key: "message", value: "Hello world")), + array: .assert( + "message=Hello+world,%240", + equals: [.init(key: "message", value: "Hello world"), .init(key: "message", value: "$0")] + ), + dictionary: .assert( + "message=R,Hello+world,G,%24%24%24,G,%40%40%40", + equals: [ + .init(key: "message/R", value: "Hello world"), .init(key: "message/G", value: "$$$"), + .init(key: "message/G", value: "@@@"), + ] + ) + ), + deepObjectExplode: .init( + rootKey: "message", + primitive: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + array: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + dictionary: .assert( + "message%5BR%5D=Hello%20world&message%5BG%5D=%24%24%24&message%5BG%5D=%40%40%40", + equals: [ + .init(key: "message/R", value: "Hello world"), .init(key: "message/G", value: "$$$"), + .init(key: "message/G", value: "@@@"), + ] + ) + ) + ) + + // Missing/nil + try testCase( + formExplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&suffix=baz", equals: nil), + array: .assert("prefix=bar&suffix=baz", equals: []), + dictionary: .assert("", equals: []) + ), + formUnexplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&suffix=baz", equals: nil), + array: .assert("prefix=bar&suffix=baz", equals: []), + dictionary: .assert("prefix=bar&suffix=baz", equals: []) + ), + simpleExplode: .init( + rootKey: "color", + primitive: .assert("", equals: .init(key: .empty, value: "")), + array: .assert("", equals: []), + dictionary: .assert("", equals: []) + ), + simpleUnexplode: .init( + rootKey: "color", + primitive: .assert("", equals: .init(key: .empty, value: "")), + array: .assert("", equals: []), + dictionary: .assert("", equals: []) + ), + formDataExplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&suffix=baz", equals: nil), + array: .assert("prefix=bar&suffix=baz", equals: []), + dictionary: .assert("", equals: []) + ), + formDataUnexplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&suffix=baz", equals: nil), + array: .assert("prefix=bar&suffix=baz", equals: []), + dictionary: .assert("prefix=bar&suffix=baz", equals: []) + ), + deepObjectExplode: .init( + rootKey: "color", + primitive: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + array: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + dictionary: .assert("prefix%5Bfoo%5D=1&suffix%5Bbaz%5D=2", equals: []) + ) + ) + + // Empty value (distinct from missing/nil, but some cases overlap) + try testCase( + formExplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=&suffix=baz", equals: .init(key: "color", value: "")), + array: .assert("prefix=bar&color=&suffix=baz", equals: [.init(key: "color", value: "")]), + dictionary: .assert( + "R=&G=200&G=150", + equals: [.init(key: "R", value: ""), .init(key: "G", value: "200"), .init(key: "G", value: "150")] + ) + ), + formUnexplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=&suffix=baz", equals: .init(key: "color", value: "")), + array: .assert("prefix=bar&color=&suffix=baz", equals: [.init(key: "color", value: "")]), + dictionary: .assert( + "prefix=bar&color=R,,G,200,G,150&suffix=baz", + equals: [ + .init(key: "color/R", value: ""), .init(key: "color/G", value: "200"), + .init(key: "color/G", value: "150"), + ] + ) + ), + simpleExplode: .init( + rootKey: "color", + primitive: .assert("", equals: .init(key: .empty, value: "")), + array: .assert( + ",black,brown", + equals: [ + .init(key: .empty, value: ""), .init(key: .empty, value: "black"), + .init(key: .empty, value: "brown"), + ] + ), + dictionary: .assert( + "R=,G=200,G=150", + equals: [.init(key: "R", value: ""), .init(key: "G", value: "200"), .init(key: "G", value: "150")] + ) + ), + simpleUnexplode: .init( + rootKey: "color", + primitive: .assert("", equals: .init(key: .empty, value: "")), + array: .assert( + ",black,brown", + equals: [ + .init(key: .empty, value: ""), .init(key: .empty, value: "black"), + .init(key: .empty, value: "brown"), + ] + ), + dictionary: .assert( + "R,,G,200,G,150", + equals: [.init(key: "R", value: ""), .init(key: "G", value: "200"), .init(key: "G", value: "150")] + ) + ), + formDataExplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=&suffix=baz", equals: .init(key: "color", value: "")), + array: .assert( + "prefix=bar&color=&color=black&color=brown&suffix=baz", + equals: [ + .init(key: "color", value: ""), .init(key: "color", value: "black"), + .init(key: "color", value: "brown"), + ] + ), + dictionary: .assert( + "R=&G=200&G=150", + equals: [.init(key: "R", value: ""), .init(key: "G", value: "200"), .init(key: "G", value: "150")] + ) + ), + formDataUnexplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=&suffix=baz", equals: .init(key: "color", value: "")), + array: .assert( + "prefix=bar&color=,black,brown&suffix=baz", + equals: [ + .init(key: "color", value: ""), .init(key: "color", value: "black"), + .init(key: "color", value: "brown"), + ] + ), + dictionary: .assert( + "prefix=bar&color=R,,G,200,G,150&suffix=baz", + equals: [ + .init(key: "color/R", value: ""), .init(key: "color/G", value: "200"), + .init(key: "color/G", value: "150"), + ] + ) + ), + deepObjectExplode: .init( + rootKey: "color", + primitive: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + array: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + dictionary: .assert( + "prefix%5Bfoo%5D=1&color%5BR%5D=&color%5BG%5D=200&color%5BG%5D=150&suffix%5Bbaz%5D=2", + equals: [ + .init(key: "color/R", value: ""), .init(key: "color/G", value: "200"), + .init(key: "color/G", value: "150"), + ] + ) + ) + ) } -} -extension Test_URIParser { struct Case { struct Variant { var name: String @@ -158,33 +516,33 @@ extension Test_URIParser { 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, 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, expectedError: nil) - } - static func custom(_ string: String, expectedError: ParsingError) -> Self { - .init(string: string, valueOverride: nil, expectedError: expectedError) - } + struct RootInput { + var string: String + enum ExpectedResult { + case success(RootType) + case failure((ParsingError) -> Void) + } + var result: ExpectedResult - init(stringLiteral value: String) { - self.string = value - self.valueOverride = nil - self.expectedError = nil - } + init(string: String, result: ExpectedResult) { + self.string = string + self.result = result } + static func assert(_ string: String, equals value: RootType) -> Self { + .init(string: string, result: .success(value)) + } + static func assert(_ string: String, validateError: @escaping (ParsingError) -> Void) -> Self { + .init(string: string, result: .failure(validateError)) + } + } + struct Input { + var rootKey: URIParsedKeyComponent + var primitive: RootInput + var array: RootInput<[URIParsedPair]> + var dictionary: RootInput<[URIParsedPair]> + } + struct Variants { var formExplode: Input var formUnexplode: Input var simpleExplode: Input @@ -194,11 +552,92 @@ extension Test_URIParser { var deepObjectExplode: Input } var variants: Variants - var value: URIParsedNode var file: StaticString = #file var line: UInt = #line } - func makeCase(_ variants: Case.Variants, value: URIParsedNode, file: StaticString = #file, line: UInt = #line) - -> Case - { .init(variants: variants, value: value, file: file, line: line) } + + func testCase(_ variants: Case.Variants, file: StaticString = #file, line: UInt = #line) throws { + let caseValue = Case(variants: variants, file: file, line: line) + func testVariant(_ variant: Case.Variant, _ input: Case.Input) throws { + func testRoot( + rootName: String, + _ root: Case.RootInput, + parse: (URIParser) throws -> RootType + ) throws { + let parser = URIParser(configuration: variant.config, data: root.string[...]) + switch root.result { + case .success(let expectedValue): + let parsedValue = try parse(parser) + XCTAssertEqual( + parsedValue, + expectedValue, + "Failed for config: \(variant.name), root: \(rootName)", + file: caseValue.file, + line: caseValue.line + ) + case .failure(let validateError): + do { + _ = try parse(parser) + XCTFail("Should have thrown an error", file: caseValue.file, line: caseValue.line) + } catch { + guard let parsingError = error as? ParsingError else { + XCTAssert( + false, + "Unexpected error thrown: \(error)", + file: caseValue.file, + line: caseValue.line + ) + return + } + validateError(parsingError) + } + } + } + try testRoot( + rootName: "primitive", + input.primitive, + parse: { try $0.parseRootAsPrimitive(rootKey: input.rootKey) } + ) + try testRoot(rootName: "array", input.array, parse: { try $0.parseRootAsArray(rootKey: input.rootKey) }) + try testRoot( + rootName: "dictionary", + input.dictionary, + parse: { try $0.parseRootAsDictionary(rootKey: input.rootKey) } + ) + } + let variants = caseValue.variants + try testVariant(.formExplode, variants.formExplode) + try testVariant(.formUnexplode, variants.formUnexplode) + try testVariant(.simpleExplode, variants.simpleExplode) + try testVariant(.simpleUnexplode, variants.simpleUnexplode) + try testVariant(.formDataExplode, variants.formDataExplode) + try testVariant(.formDataUnexplode, variants.formDataUnexplode) + try testVariant(.deepObjectExplode, variants.deepObjectExplode) + } + + func testCase( + formExplode: Case.Input, + formUnexplode: Case.Input, + simpleExplode: Case.Input, + simpleUnexplode: Case.Input, + formDataExplode: Case.Input, + formDataUnexplode: Case.Input, + deepObjectExplode: Case.Input, + file: StaticString = #file, + line: UInt = #line + ) throws { + try testCase( + .init( + formExplode: formExplode, + formUnexplode: formUnexplode, + simpleExplode: simpleExplode, + simpleUnexplode: simpleUnexplode, + formDataExplode: formDataExplode, + formDataUnexplode: formDataUnexplode, + deepObjectExplode: deepObjectExplode + ), + file: file, + line: line + ) + } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift index f198b6eb..a42c9913 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift @@ -16,10 +16,6 @@ import XCTest final class Test_URISerializer: Test_Runtime { - let testedVariants: [URICoderConfiguration] = [ - .formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode, - ] - func testSerializing() throws { let cases: [Case] = [ makeCase( diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift index 3f351768..ac1fc00f 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift @@ -97,7 +97,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "", formDataExplode: "root=", formDataUnexplode: "root=", - deepObjectExplode: .custom("root=", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -112,10 +112,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "Hello%20World%21", formDataExplode: "root=Hello+World%21", formDataUnexplode: "root=Hello+World%21", - deepObjectExplode: .custom( - "root=Hello%20World%21", - expectedError: .deepObjectsWithPrimitiveValuesNotSupported - ) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -130,7 +127,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "red", formDataExplode: "root=red", formDataUnexplode: "root=red", - deepObjectExplode: .custom("root=red", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -145,7 +142,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "1234", formDataExplode: "root=1234", formDataUnexplode: "root=1234", - deepObjectExplode: .custom("root=1234", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -160,7 +157,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "12.34", formDataExplode: "root=12.34", formDataUnexplode: "root=12.34", - deepObjectExplode: .custom("root=12.34", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -175,7 +172,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "true", formDataExplode: "root=true", formDataUnexplode: "root=true", - deepObjectExplode: .custom("root=true", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -190,10 +187,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "2023-08-25T07%3A34%3A59Z", formDataExplode: "root=2023-08-25T07%3A34%3A59Z", formDataUnexplode: "root=2023-08-25T07%3A34%3A59Z", - deepObjectExplode: .custom( - "root=2023-08-25T07%3A34%3A59Z", - expectedError: .deepObjectsWithPrimitiveValuesNotSupported - ) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -208,7 +202,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "a,b,c", formDataExplode: "list=a&list=b&list=c", formDataUnexplode: "list=a,b,c", - deepObjectExplode: .custom("list=a&list=b&list=c", expectedError: .deepObjectsArrayNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -223,10 +217,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z", formDataExplode: "list=2023-08-25T07%3A34%3A59Z&list=2023-08-25T07%3A35%3A01Z", formDataUnexplode: "list=2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z", - deepObjectExplode: .custom( - "list=2023-08-25T07%3A34%3A59Z&list=2023-08-25T07%3A35%3A01Z", - expectedError: .deepObjectsArrayNotSupported - ) + deepObjectExplode: .custom("", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -237,8 +228,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { .init( formExplode: "", formUnexplode: "", - simpleExplode: .custom("", value: [""]), - simpleUnexplode: .custom("", value: [""]), + simpleExplode: "", + simpleUnexplode: "", formDataExplode: "", formDataUnexplode: "", deepObjectExplode: .custom("", expectedError: .deepObjectsArrayNotSupported) @@ -256,10 +247,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "red,green,blue", formDataExplode: "list=red&list=green&list=blue", formDataUnexplode: "list=red,green,blue", - deepObjectExplode: .custom( - "list=red&list=green&list=blue", - expectedError: .deepObjectsArrayNotSupported - ) + deepObjectExplode: .custom("", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -291,10 +279,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "2023-01-18T10%3A04%3A11Z", formDataExplode: "root=2023-01-18T10%3A04%3A11Z", formDataUnexplode: "root=2023-01-18T10%3A04%3A11Z", - deepObjectExplode: .custom( - "root=2023-01-18T10%3A04%3A11Z", - expectedError: .deepObjectsWithPrimitiveValuesNotSupported - ) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) try _test( @@ -307,7 +292,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "green", formDataExplode: "root=green", formDataUnexplode: "root=green", - deepObjectExplode: .custom("root=green", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) try _test( @@ -362,8 +347,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { .init( formExplode: "", formUnexplode: "", - simpleExplode: .custom("", value: ["": ""]), - simpleUnexplode: .custom("", value: ["": ""]), + simpleExplode: "", + simpleUnexplode: "", formDataExplode: "", formDataUnexplode: "", deepObjectExplode: "" diff --git a/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift b/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift index 65235d82..38462a6a 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift @@ -59,6 +59,7 @@ extension URICoderConfiguration { spaceEscapingCharacter: .plus, dateTranscoder: defaultDateTranscoder ) + static let deepObjectExplode: Self = .init( style: .deepObject, explode: true, @@ -66,3 +67,11 @@ extension URICoderConfiguration { dateTranscoder: defaultDateTranscoder ) } + +extension URIParsedKey: ExpressibleByStringLiteral { + + /// Creates an instance initialized to the given string value. + /// + /// - Parameter value: The value of the new instance. + public init(stringLiteral value: StringLiteralType) { self.init(value.split(separator: "/").map { $0[...] }) } +}