From 0769bd0ff4b382aa51c7039026a1f3c3cf3839ef Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Tue, 29 Aug 2023 14:54:31 +0200 Subject: [PATCH] Introduce URIEncoder and URIDecoder types (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce URIEncoder and URIDecoder types ### Motivation Fixes https://github.com/apple/swift-openapi-generator/issues/192. For refactoring how we encode: - path parameters - query parameters - headers - (new feature, coming later) `application/x-www-form-urlencoded` bodies Supports: - form + explode - form + unexplode - simple + explode - simple + unexplode - form where space is encoded as + instead of %20 (for bodies) + explode - form where space is encoded as + instead of %20 (for bodies) + unexplode ### Modifications First step - introduce two new types: `URIEncoder` and `URIDecoder`. They're configurable types that can handle the URI Template (RFC 6570) form and simple styles, refined by OpenAPI 3.0.3, and also the `application/x-www-form-urlencoded` format (mostly identical to URI Template). ### Result The types can be used now, subsequent PRs will integrate them. ### Test Plan Added unit tests for the individual parts of the coder, but also roundtrip tests. Reviewed by: glbrntt Builds: ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - Build finished. ✔︎ pull request validation (api breakage) - Build finished. ✔︎ pull request validation (docc test) - Build finished. ✔︎ pull request validation (integration test) - Build finished. ✔︎ pull request validation (nightly) - Build finished. ✔︎ pull request validation (soundness) - Build finished. https://github.com/apple/swift-openapi-runtime/pull/41 --- NOTICE.txt | 9 + .../URICoder/Common/URICodeCodingKey.swift | 46 +++ .../Common/URICoderConfiguration.swift | 52 +++ .../URICoder/Common/URIEncodedNode.swift | 149 +++++++ .../URICoder/Common/URIParsedNode.swift | 27 ++ .../URICoder/Decoding/URIDecoder.swift | 136 +++++++ .../URIValueFromNodeDecoder+Keyed.swift | 250 ++++++++++++ .../URIValueFromNodeDecoder+Single.swift | 188 +++++++++ .../URIValueFromNodeDecoder+Unkeyed.swift | 261 ++++++++++++ .../Decoding/URIValueFromNodeDecoder.swift | 365 +++++++++++++++++ .../URICoder/Encoding/URIEncoder.swift | 92 +++++ .../URIValueToNodeEncoder+Keyed.swift | 197 +++++++++ .../URIValueToNodeEncoder+Single.swift | 150 +++++++ .../URIValueToNodeEncoder+Unkeyed.swift | 183 +++++++++ .../Encoding/URIValueToNodeEncoder.swift | 146 +++++++ .../URICoder/Parsing/URIParser.swift | 356 ++++++++++++++++ .../Serialization/URISerializer.swift | 312 ++++++++++++++ .../URICoder/Decoder/Test_URIDecoder.swift | 31 ++ .../Test_URIValueFromNodeDecoder.swift | 157 ++++++++ .../URICoder/Encoding/Test_URIEncoder.swift | 31 ++ .../Encoding/Test_URIValueToNodeEncoder.swift | 270 +++++++++++++ .../URICoder/Parsing/Test_URIParser.swift | 230 +++++++++++ .../Serialization/Test_URISerializer.swift | 220 ++++++++++ .../URICoder/Test_URICodingRoundtrip.swift | 380 ++++++++++++++++++ .../URICoder/URICoderTestUtils.swift | 62 +++ 25 files changed, 4300 insertions(+) create mode 100644 Sources/OpenAPIRuntime/URICoder/Common/URICodeCodingKey.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Keyed.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Single.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift create mode 100644 Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift create mode 100644 Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift create mode 100644 Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift create mode 100644 Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift create mode 100644 Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift create mode 100644 Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift create mode 100644 Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift create mode 100644 Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift diff --git a/NOTICE.txt b/NOTICE.txt index d2e20c1a..7b160cf4 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -32,3 +32,12 @@ This product contains derivations of various scripts and templates from SwiftNIO * https://www.apache.org/licenses/LICENSE-2.0 * HOMEPAGE: * https://github.com/apple/swift-nio + +--- + +This product contains coder implementations inspired by swift-http-structured-headers. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/apple/swift-http-structured-headers diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URICodeCodingKey.swift b/Sources/OpenAPIRuntime/URICoder/Common/URICodeCodingKey.swift new file mode 100644 index 00000000..48744267 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Common/URICodeCodingKey.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// 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 coding key used by the URI encoder and decoder. +struct URICoderCodingKey { + + /// The string to use in a named collection (e.g. a string-keyed dictionary). + var stringValue: String + + /// The value to use in an integer-indexed collection (e.g. an int-keyed + /// dictionary). + var intValue: Int? + + /// Creates a new key with the same string and int value as the provided key. + /// - Parameter key: The key whose values to copy. + init(_ key: some CodingKey) { + self.stringValue = key.stringValue + self.intValue = key.intValue + } +} + +extension URICoderCodingKey: CodingKey { + + init(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift b/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift new file mode 100644 index 00000000..bfb42c48 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// +// 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 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. + enum Style { + + /// A style for simple string variable expansion. + case simple + + /// A style for form-based URI expansion. + case form + } + + /// A character used to escape the space character. + enum SpaceEscapingCharacter: String { + + /// A percent encoded value for the space character. + case percentEncoded = "%20" + + /// The plus character. + case plus = "+" + } + + /// The variable expansion style. + 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. + var explode: Bool + + /// The character used to escape the space character. + var spaceEscapingCharacter: SpaceEscapingCharacter + + /// The coder used for serializing the Date type. + var dateTranscoder: any DateTranscoder +} diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift b/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift new file mode 100644 index 00000000..4dc882a4 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift @@ -0,0 +1,149 @@ +//===----------------------------------------------------------------------===// +// +// 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 node produced by `URIValueToNodeEncoder`. +enum URIEncodedNode: Equatable { + + /// No value. + case unset + + /// A single primitive value. + case primitive(Primitive) + + /// An array of nodes. + case array([Self]) + + /// A dictionary with node values. + case dictionary([String: Self]) + + /// A primitive value. + enum Primitive: Equatable { + + /// A boolean value. + case bool(Bool) + + /// A string value. + case string(String) + + /// An integer value. + case integer(Int) + + /// A floating-point value. + case double(Double) + + /// A date value. + case date(Date) + } +} + +extension URIEncodedNode { + + /// An error thrown by the methods modifying `URIEncodedNode`. + enum InsertionError: Swift.Error { + + /// The encoder encoded a second primitive value. + case settingPrimitiveValueAgain + + /// The encoder set a single value on a container. + case settingValueOnAContainer + + /// The encoder appended to a node that wasn't an array. + case appendingToNonArrayContainer + + /// The encoder inserted a value for key into a node that wasn't + /// a dictionary. + case insertingChildValueIntoNonContainer + + /// The encoder added a value to an array, but the key was not a valid + /// integer key. + case insertingChildValueIntoArrayUsingNonIntValueKey + } + + /// Sets the node to be a primitive node with the provided value. + /// - Parameter value: The primitive value to set into the node. + /// - Throws: If the node is already set. + mutating func set(_ value: Primitive) throws { + switch self { + case .unset: + self = .primitive(value) + case .primitive: + throw InsertionError.settingPrimitiveValueAgain + case .array, .dictionary: + throw InsertionError.settingValueOnAContainer + } + } + + /// Inserts a value for a key into the node, which is interpreted as a + /// dictionary. + /// - Parameters: + /// - childValue: The value to save under the provided key. + /// - key: The key to save the value for into the dictionary. + /// - Throws: If the node is already set to be anything else but a + /// dictionary. + mutating func insert( + _ childValue: Self, + atKey key: Key + ) throws { + switch self { + case .dictionary(var dictionary): + self = .unset + dictionary[key.stringValue] = childValue + self = .dictionary(dictionary) + case .array(var array): + // Check that this is a valid key for an unkeyed container, + // but don't actually extract the index, we only support appending + // here. + guard let intValue = key.intValue else { + throw InsertionError.insertingChildValueIntoArrayUsingNonIntValueKey + } + precondition( + intValue == array.count, + "Unkeyed container inserting at an incorrect index" + ) + self = .unset + array.append(childValue) + self = .array(array) + case .unset: + if let intValue = key.intValue { + precondition( + intValue == 0, + "Unkeyed container inserting at an incorrect index" + ) + self = .array([childValue]) + } else { + self = .dictionary([key.stringValue: childValue]) + } + default: + throw InsertionError.insertingChildValueIntoNonContainer + } + } + + /// 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. + mutating func append(_ childValue: Self) throws { + switch self { + case .array(var items): + self = .unset + items.append(childValue) + self = .array(items) + case .unset: + self = .array([childValue]) + default: + throw InsertionError.appendingToNonArrayContainer + } + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift b/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift new file mode 100644 index 00000000..51ecbe2a --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// +// 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/Decoding/URIDecoder.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift new file mode 100644 index 00000000..d53c0341 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift @@ -0,0 +1,136 @@ +//===----------------------------------------------------------------------===// +// +// 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 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 +/// the configuration. +/// +/// [RFC 6570 - Form-style query expansion.](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8) +/// +/// | Example Template | Expansion | +/// | ---------------- | ----------------------------------| +/// | `{?who}` | `?who=fred` | +/// | `{?half}` | `?half=50%25` | +/// | `{?x,y}` | `?x=1024&y=768` | +/// | `{?x,y,empty}` | `?x=1024&y=768&empty=` | +/// | `{?x,y,undef}` | `?x=1024&y=768` | +/// | `{?list}` | `?list=red,green,blue` | +/// | `{?list\*}` | `?list=red&list=green&list=blue` | +/// | `{?keys}` | `?keys=semi,%3B,dot,.,comma,%2C` | +/// | `{?keys\*}` | `?semi=%3B&dot=.&comma=%2C` | +/// +/// [RFC 6570 - Simple string expansion.](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.2) +/// +/// | Example Template | Expansion | +/// | ---------------- | ----------------------------------| +/// | `{hello}` | `Hello%20World%21` | +/// | `{half}` | `50%25` | +/// | `{x,y}` | `1024,768` | +/// | `{x,empty}` | `1024,` | +/// | `{x,undef}` | `1024` | +/// | `{list}` | `red,green,blue` | +/// | `{list\*}` | `red,green,blue` | +/// | `{keys}` | `semi,%3B,dot,.,comma,%2C` | +/// | `{keys\*}` | `semi=%3B,dot=.,comma=%2C` | +struct URIDecoder: Sendable { + + /// The configuration instructing the decoder how to interpret the raw + /// string. + private let configuration: URICoderConfiguration + + /// Creates a new decoder with the provided configuration. + /// - Parameter configuration: The configuration used by the decoder. + init(configuration: URICoderConfiguration) { + self.configuration = configuration + } +} + +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 + /// and explode options, ignored otherwise. + /// - data: The URI-encoded string. + /// - Returns: The decoded value. + func decode( + _ type: T.Type = T.self, + forKey key: String = "", + from data: String + ) throws -> T { + try withCachedParser(from: data) { decoder in + try decoder.decode(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. + func withCachedParser( + from data: String, + 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. + 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() + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift new file mode 100644 index 00000000..03485d6b --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift @@ -0,0 +1,250 @@ +//===----------------------------------------------------------------------===// +// +// 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 keyed container used by `URIValueFromNodeDecoder`. +struct URIKeyedDecodingContainer { + + /// The associated decoder. + let decoder: URIValueFromNodeDecoder + + /// The underlying dictionary. + let values: URIParsedNode +} + +extension URIKeyedDecodingContainer { + + /// Returns the value found for the provided key in the underlying + /// dictionary. + /// - Parameter key: The key for which to return the value. + /// - 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 { + throw DecodingError.keyNotFound( + key, + .init(codingPath: codingPath, debugDescription: "Key not found.") + ) + } + return value + } + + /// Returns the value found for the provided key in the underlying + /// dictionary converted to the provided type. + /// - Parameter key: The key for which to return the value. + /// - Returns: The converted value found for the provided key. + /// - Throws: An error if no value for the key was found or if the + /// conversion failed. + private func _decodeBinaryFloatingPoint( + _: T.Type = T.self, + forKey key: Key + ) throws -> T { + guard let double = Double(try _decodeValue(forKey: key)) else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: codingPath, + debugDescription: "Failed to convert to Double." + ) + ) + } + return T(double) + } + + /// Returns the value found for the provided key in the underlying + /// dictionary converted to the provided type. + /// - Parameter key: The key for which to return the value. + /// - Returns: The converted value found for the provided key. + /// - Throws: An error if no value for the key was found or if the + /// conversion failed. + private func _decodeFixedWidthInteger( + _: T.Type = T.self, + forKey key: Key + ) throws -> T { + guard let parsedValue = T(try _decodeValue(forKey: key)) else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: codingPath, + debugDescription: "Failed to convert to the requested type." + ) + ) + } + return parsedValue + } + + /// Returns the value found for the provided key in the underlying + /// dictionary converted to the provided type. + /// - Parameter key: The key for which to return the value. + /// - Returns: The converted value found for the provided key. + /// - Throws: An error if no value for the key was found or if the + /// conversion failed. + private func _decodeNextLosslessStringConvertible( + _: T.Type = T.self, + forKey key: Key + ) throws -> T { + guard let parsedValue = T(String(try _decodeValue(forKey: key))) else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: codingPath, + debugDescription: "Failed to convert to the requested type." + ) + ) + } + return parsedValue + } +} + +extension URIKeyedDecodingContainer: KeyedDecodingContainerProtocol { + + var allKeys: [Key] { + values.keys.map { key in + Key.init(stringValue: String(key))! + } + } + + func contains(_ key: Key) -> Bool { + values[key.stringValue[...]] != nil + } + + var codingPath: [any CodingKey] { + decoder.codingPath + } + + func decodeNil(forKey key: Key) -> Bool { + false + } + + func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { + try _decodeNextLosslessStringConvertible(forKey: key) + } + + func decode(_ type: String.Type, forKey key: Key) throws -> String { + String(try _decodeValue(forKey: key)) + } + + func decode(_ type: Double.Type, forKey key: Key) throws -> Double { + try _decodeBinaryFloatingPoint(forKey: key) + } + + func decode(_ type: Float.Type, forKey key: Key) throws -> Float { + try _decodeBinaryFloatingPoint(forKey: key) + } + + func decode(_ type: Int.Type, forKey key: Key) throws -> Int { + try _decodeFixedWidthInteger(forKey: key) + } + + func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { + try _decodeFixedWidthInteger(forKey: key) + } + + func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { + try _decodeFixedWidthInteger(forKey: key) + } + + func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { + try _decodeFixedWidthInteger(forKey: key) + } + + func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { + try _decodeFixedWidthInteger(forKey: key) + } + + func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { + try _decodeFixedWidthInteger(forKey: key) + } + + func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { + try _decodeFixedWidthInteger(forKey: key) + } + + func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { + try _decodeFixedWidthInteger(forKey: key) + } + + func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { + try _decodeFixedWidthInteger(forKey: key) + } + + func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { + try _decodeFixedWidthInteger(forKey: key) + } + + func decode(_ type: T.Type, forKey key: Key) throws -> T where T: Decodable { + switch type { + case is Bool.Type: + return try decode(Bool.self, forKey: key) as! T + case is String.Type: + return try decode(String.self, forKey: key) as! T + case is Double.Type: + return try decode(Double.self, forKey: key) as! T + case is Float.Type: + return try decode(Float.self, forKey: key) as! T + case is Int.Type: + return try decode(Int.self, forKey: key) as! T + case is Int8.Type: + return try decode(Int8.self, forKey: key) as! T + case is Int16.Type: + return try decode(Int16.self, forKey: key) as! T + case is Int32.Type: + return try decode(Int32.self, forKey: key) as! T + case is Int64.Type: + return try decode(Int64.self, forKey: key) as! T + case is UInt.Type: + return try decode(UInt.self, forKey: key) as! T + case is UInt8.Type: + return try decode(UInt8.self, forKey: key) as! T + case is UInt16.Type: + return try decode(UInt16.self, forKey: key) as! T + case is UInt32.Type: + return try decode(UInt32.self, forKey: key) as! T + 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)) + defer { + decoder.pop() + } + return try type.init(from: decoder) + } + } + + func nestedContainer( + keyedBy type: NestedKey.Type, + forKey key: Key + ) throws -> KeyedDecodingContainer where NestedKey: CodingKey { + throw URIValueFromNodeDecoder.GeneralError.nestedContainersNotSupported + } + + func nestedUnkeyedContainer( + forKey key: Key + ) throws -> any UnkeyedDecodingContainer { + throw URIValueFromNodeDecoder.GeneralError.nestedContainersNotSupported + } + + func superDecoder(forKey key: Key) throws -> any Decoder { + decoder + } + + func superDecoder() throws -> any Decoder { + decoder + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift new file mode 100644 index 00000000..5c3e0ad2 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift @@ -0,0 +1,188 @@ +//===----------------------------------------------------------------------===// +// +// 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 single value container used by `URIValueFromNodeDecoder`. +struct URISingleValueDecodingContainer { + + /// The coder used to serialize Date values. + let dateTranscoder: any DateTranscoder + + /// The coding path of the container. + let codingPath: [any CodingKey] + + /// The underlying value. + let value: URIParsedValue +} + +extension URISingleValueDecodingContainer { + + /// Returns the value found in the underlying node converted to + /// the provided type. + /// - Returns: The converted value found. + /// - Throws: An error if the conversion failed. + private func _decodeBinaryFloatingPoint( + _: T.Type = T.self + ) throws -> T { + guard let double = Double(value) else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: codingPath, + debugDescription: "Failed to convert to Double." + ) + ) + } + return T(double) + } + + /// Returns the value found in the underlying node converted to + /// the provided type. + /// - Returns: The converted value found. + /// - Throws: An error if the conversion failed. + private func _decodeFixedWidthInteger( + _: T.Type = T.self + ) throws -> T { + guard let parsedValue = T(value) else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: codingPath, + debugDescription: "Failed to convert to the requested type." + ) + ) + } + return parsedValue + } + + /// Returns the value found in the underlying node converted to + /// the provided type. + /// - Returns: The converted value found. + /// - Throws: An error if the conversion failed. + private func _decodeLosslessStringConvertible( + _: T.Type = T.self + ) throws -> T { + guard let parsedValue = T(String(value)) else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: codingPath, + debugDescription: "Failed to convert to the requested type." + ) + ) + } + return parsedValue + } +} + +extension URISingleValueDecodingContainer: SingleValueDecodingContainer { + + func decodeNil() -> Bool { + false + } + + func decode(_ type: Bool.Type) throws -> Bool { + try _decodeLosslessStringConvertible() + } + + func decode(_ type: String.Type) throws -> String { + String(value) + } + + func decode(_ type: Double.Type) throws -> Double { + try _decodeBinaryFloatingPoint() + } + + func decode(_ type: Float.Type) throws -> Float { + try _decodeBinaryFloatingPoint() + } + + func decode(_ type: Int.Type) throws -> Int { + try _decodeFixedWidthInteger() + } + + func decode(_ type: Int8.Type) throws -> Int8 { + try _decodeFixedWidthInteger() + } + + func decode(_ type: Int16.Type) throws -> Int16 { + try _decodeFixedWidthInteger() + } + + func decode(_ type: Int32.Type) throws -> Int32 { + try _decodeFixedWidthInteger() + } + + func decode(_ type: Int64.Type) throws -> Int64 { + try _decodeFixedWidthInteger() + } + + func decode(_ type: UInt.Type) throws -> UInt { + try _decodeFixedWidthInteger() + } + + func decode(_ type: UInt8.Type) throws -> UInt8 { + try _decodeFixedWidthInteger() + } + + func decode(_ type: UInt16.Type) throws -> UInt16 { + try _decodeFixedWidthInteger() + } + + func decode(_ type: UInt32.Type) throws -> UInt32 { + try _decodeFixedWidthInteger() + } + + func decode(_ type: UInt64.Type) throws -> UInt64 { + try _decodeFixedWidthInteger() + } + + func decode(_ type: T.Type) throws -> T where T: Decodable { + switch type { + case is Bool.Type: + return try decode(Bool.self) as! T + case is String.Type: + return try decode(String.self) as! T + case is Double.Type: + return try decode(Double.self) as! T + case is Float.Type: + return try decode(Float.self) as! T + case is Int.Type: + return try decode(Int.self) as! T + case is Int8.Type: + return try decode(Int8.self) as! T + case is Int16.Type: + return try decode(Int16.self) as! T + case is Int32.Type: + return try decode(Int32.self) as! T + case is Int64.Type: + return try decode(Int64.self) as! T + case is UInt.Type: + return try decode(UInt.self) as! T + case is UInt8.Type: + return try decode(UInt8.self) as! T + 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 dateTranscoder.decode(String(value)) as! T + default: + throw URIValueFromNodeDecoder.GeneralError.unsupportedType(T.self) + } + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift new file mode 100644 index 00000000..abb55d7a --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift @@ -0,0 +1,261 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// 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 + + /// 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) { + self.decoder = decoder + self.values = values + self.index = values.startIndex + } +} + +extension URIUnkeyedDecodingContainer { + + /// Returns the result from the provided closure run on the current + /// item in the underlying array and increments the index. + /// - Parameter work: The closure of work to run for the current item. + /// - Returns: The result of the closure. + /// - 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) + } + return try work() + } + + /// Returns the the current item in the underlying array and increments + /// the index. + /// - 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] + } + } + + /// Returns the next value converted to the provided type. + /// - Returns: The converted value. + /// - Throws: An error if the container ran out of items or if + /// the conversion failed. + private mutating func _decodeNextBinaryFloatingPoint( + _: T.Type = T.self + ) throws -> T { + guard let double = Double(try _decodeNext()) else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: codingPath, + debugDescription: "Failed to convert to Double." + ) + ) + } + return T(double) + } + + /// Returns the next value converted to the provided type. + /// - Returns: The converted value. + /// - Throws: An error if the container ran out of items or if + /// the conversion failed. + private mutating func _decodeNextFixedWidthInteger( + _: T.Type = T.self + ) throws -> T { + guard let parsedValue = T(try _decodeNext()) else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: codingPath, + debugDescription: "Failed to convert to the requested type." + ) + ) + } + return parsedValue + } + + /// Returns the next value converted to the provided type. + /// - Returns: The converted value. + /// - Throws: An error if the container ran out of items or if + /// the conversion failed. + private mutating func _decodeNextLosslessStringConvertible( + _: T.Type = T.self + ) throws -> T { + guard let parsedValue = T(String(try _decodeNext())) else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: codingPath, + debugDescription: "Failed to convert to the requested type." + ) + ) + } + return parsedValue + } +} + +extension URIUnkeyedDecodingContainer: UnkeyedDecodingContainer { + + var count: Int? { + values.count + } + + var isAtEnd: Bool { + index == values.endIndex + } + + var currentIndex: Int { + index + } + + var codingPath: [any CodingKey] { + decoder.codingPath + } + + func decodeNil() -> Bool { + false + } + + mutating func decode(_ type: Bool.Type) throws -> Bool { + try _decodeNextLosslessStringConvertible() + } + + mutating func decode(_ type: String.Type) throws -> String { + String(try _decodeNext()) + } + + mutating func decode(_ type: Double.Type) throws -> Double { + try _decodeNextBinaryFloatingPoint() + } + + mutating func decode(_ type: Float.Type) throws -> Float { + try _decodeNextBinaryFloatingPoint() + } + + mutating func decode(_ type: Int.Type) throws -> Int { + try _decodeNextFixedWidthInteger() + } + + mutating func decode(_ type: Int8.Type) throws -> Int8 { + try _decodeNextFixedWidthInteger() + } + + mutating func decode(_ type: Int16.Type) throws -> Int16 { + try _decodeNextFixedWidthInteger() + } + + mutating func decode(_ type: Int32.Type) throws -> Int32 { + try _decodeNextFixedWidthInteger() + } + + mutating func decode(_ type: Int64.Type) throws -> Int64 { + try _decodeNextFixedWidthInteger() + } + + mutating func decode(_ type: UInt.Type) throws -> UInt { + try _decodeNextFixedWidthInteger() + } + + mutating func decode(_ type: UInt8.Type) throws -> UInt8 { + try _decodeNextFixedWidthInteger() + } + + mutating func decode(_ type: UInt16.Type) throws -> UInt16 { + try _decodeNextFixedWidthInteger() + } + + mutating func decode(_ type: UInt32.Type) throws -> UInt32 { + try _decodeNextFixedWidthInteger() + } + + mutating func decode(_ type: UInt64.Type) throws -> UInt64 { + try _decodeNextFixedWidthInteger() + } + + mutating func decode(_ type: T.Type) throws -> T where T: Decodable { + switch type { + case is Bool.Type: + return try decode(Bool.self) as! T + case is String.Type: + return try decode(String.self) as! T + case is Double.Type: + return try decode(Double.self) as! T + case is Float.Type: + return try decode(Float.self) as! T + case is Int.Type: + return try decode(Int.self) as! T + case is Int8.Type: + return try decode(Int8.self) as! T + case is Int16.Type: + return try decode(Int16.self) as! T + case is Int32.Type: + return try decode(Int32.self) as! T + case is Int64.Type: + return try decode(Int64.self) as! T + case is UInt.Type: + return try decode(UInt.self) as! T + case is UInt8.Type: + return try decode(UInt8.self) as! T + 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(_decodeNext())) as! T + default: + return try _decodingNext { [decoder, currentIndex] in + try decoder.push(.init(intValue: currentIndex)) + defer { + decoder.pop() + } + return try type.init(from: decoder) + } + } + } + + mutating func nestedContainer( + keyedBy type: NestedKey.Type + ) throws -> KeyedDecodingContainer where NestedKey: CodingKey { + throw URIValueFromNodeDecoder.GeneralError.nestedContainersNotSupported + } + + mutating func nestedUnkeyedContainer() throws -> any UnkeyedDecodingContainer { + throw URIValueFromNodeDecoder.GeneralError.nestedContainersNotSupported + } + + mutating func superDecoder() throws -> any Decoder { + decoder + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift new file mode 100644 index 00000000..772123eb --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift @@ -0,0 +1,365 @@ +//===----------------------------------------------------------------------===// +// +// 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 type that allows decoding `Decodable` values from a `URIParsedNode`. +final class URIValueFromNodeDecoder { + + /// The coder used for serializing Date values. + let dateTranscoder: any DateTranscoder + + /// The underlying root node. + private let node: URIParsedNode + + /// The key of the root value in the node. + private let rootKey: URIParsedKey + + /// The variable expansion style. + private let style: URICoderConfiguration.Style + + /// The explode parameter of the expansion style. + private let explode: Bool + + /// The stack of nested values within the root node. + private var codingStack: [CodingStackEntry] + + /// 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 + self.rootKey = rootKey + self.style = style + self.explode = explode + self.dateTranscoder = dateTranscoder + self.codingStack = [] + } + + /// Decodes the provided type from the root node. + /// - Parameter type: The type to decode from the decoder. + /// - Returns: The decoded value. + /// - Throws: When a decoding error occurs. + func decodeRoot(_ type: T.Type = T.self) throws -> T { + precondition(codingStack.isEmpty) + defer { + precondition(codingStack.isEmpty) + } + + // We have to catch the special values early, otherwise we fall + // back to their Codable implementations, which don't give us + // a chance to customize the coding in the containers. + let value: T + switch type { + case is Date.Type: + value = try singleValueContainer().decode(Date.self) as! T + default: + value = try T.init(from: self) + } + return value + } +} + +extension URIValueFromNodeDecoder { + + /// A decoder error. + enum GeneralError: Swift.Error { + + /// The decoder does not support the provided type. + case unsupportedType(Any.Type) + + /// The decoder was asked to create a nested container. + case nestedContainersNotSupported + + /// 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. + 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)) + } + + /// Pops the top container from the stack and restores the previously top + /// container to be the current top container. + func pop() { + codingStack.removeLast() + } + + /// Throws a type mismatch error with the provided message. + /// - Parameter message: The message to be embedded as debug description + /// inside the thrown `DecodingError`. + private func throwMismatch(_ message: String) throws -> Never { + 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. + 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 + } + return [] + } + 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. + 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.") + } + 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. + 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) + } + } + + /// 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. + private 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) + } + guard array.count == 1 else { + try throwMismatch("Cannot parse a value from a node with multiple values.") + } + 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] + } + + /// 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 + } +} + +extension URIValueFromNodeDecoder: Decoder { + + var codingPath: [any CodingKey] { + codingStack.map(\.key) + } + + 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 + ) + ) + } + + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { + let values = try currentElementAsArray() + return URIUnkeyedDecodingContainer( + decoder: self, + values: values + ) + } + + func singleValueContainer() throws -> any SingleValueDecodingContainer { + let value = try currentElementAsSingleValue() + return URISingleValueDecodingContainer( + dateTranscoder: dateTranscoder, + codingPath: codingPath, + value: value + ) + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift new file mode 100644 index 00000000..744d7a70 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// +// 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 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 +/// the configuration. +/// +/// [RFC 6570 - Form-style query expansion.](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8) +/// +/// | Example Template | Expansion | +/// | ---------------- | ----------------------------------| +/// | `{?who}` | `?who=fred` | +/// | `{?half}` | `?half=50%25` | +/// | `{?x,y}` | `?x=1024&y=768` | +/// | `{?x,y,empty}` | `?x=1024&y=768&empty=` | +/// | `{?x,y,undef}` | `?x=1024&y=768` | +/// | `{?list}` | `?list=red,green,blue` | +/// | `{?list\*}` | `?list=red&list=green&list=blue` | +/// | `{?keys}` | `?keys=semi,%3B,dot,.,comma,%2C` | +/// | `{?keys\*}` | `?semi=%3B&dot=.&comma=%2C` | +/// +/// [RFC 6570 - Simple string expansion.](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.2) +/// +/// | Example Template | Expansion | +/// | ---------------- | ----------------------------------| +/// | `{hello}` | `Hello%20World%21` | +/// | `{half}` | `50%25` | +/// | `{x,y}` | `1024,768` | +/// | `{x,empty}` | `1024,` | +/// | `{x,undef}` | `1024` | +/// | `{list}` | `red,green,blue` | +/// | `{list\*}` | `red,green,blue` | +/// | `{keys}` | `semi,%3B,dot,.,comma,%2C` | +/// | `{keys\*}` | `semi=%3B,dot=.,comma=%2C` | +struct URIEncoder: Sendable { + + /// The serializer used to turn `URIEncodedNode` values to a string. + private let serializer: URISerializer + + /// Creates a new encoder. + /// - Parameter serializer: The serializer used to turn `URIEncodedNode` + /// values to a string. + init(serializer: URISerializer) { + self.serializer = serializer + } + + /// Creates a new encoder. + /// - Parameter configuration: The configuration instructing the encoder + /// how to serialize the value into an URI-encoded string. + init(configuration: URICoderConfiguration) { + self.init(serializer: .init(configuration: configuration)) + } +} + +extension URIEncoder { + + /// Attempt to encode an object into an URI string. + /// + /// Under the hood, `URIEncoder` first encodes the `Encodable` type + /// into a `URIEncodableNode` using `URIValueToNodeEncoder`, and then + /// `URISerializer` encodes the `URIEncodableNode` into a string based + /// on the configured behavior. + /// + /// - Parameters: + /// - value: The value to encode. + /// - key: The key for which to encode the value. Can be an empty key, + /// in which case you still get a key-value pair, like `=foo`. + /// - Returns: The URI string. + func encode( + _ value: some Encodable, + forKey key: String + ) throws -> String { + let encoder = URIValueToNodeEncoder() + let node = try encoder.encodeValue(value) + var serializer = serializer + let encodedString = try serializer.serializeNode(node, forKey: key) + return encodedString + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Keyed.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Keyed.swift new file mode 100644 index 00000000..06810db6 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Keyed.swift @@ -0,0 +1,197 @@ +//===----------------------------------------------------------------------===// +// +// 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 keyed container used by `URIValueToNodeEncoder`. +struct URIKeyedEncodingContainer { + + /// The associated encoder. + let encoder: URIValueToNodeEncoder +} + +extension URIKeyedEncodingContainer { + + /// Inserts the provided node into the underlying dictionary at + /// the provided key. + /// - Parameters: + /// - node: The child node to insert. + /// - key: The key for the child node. + private func _insertValue(_ node: URIEncodedNode, atKey key: Key) throws { + try encoder.currentStackEntry.storage.insert(node, atKey: key) + } + + /// Inserts the provided primitive value into the underlying dictionary at + /// the provided key. + /// - Parameters: + /// - node: The primitive value to insert. + /// - key: The key for the value. + private func _insertValue(_ node: URIEncodedNode.Primitive, atKey key: Key) throws { + try _insertValue(.primitive(node), atKey: key) + } + + /// Inserts the provided value into the underlying dictionary at + /// the provided key. + /// - Parameters: + /// - node: The value to insert. + /// - key: The key for the value. + private func _insertBinaryFloatingPoint( + _ value: some BinaryFloatingPoint, + atKey key: Key + ) throws { + try _insertValue(.double(Double(value)), atKey: key) + } + + /// Inserts the provided value into the underlying dictionary at + /// the provided key. + /// - Parameters: + /// - node: The value to insert. + /// - key: The key for the value. + private func _insertFixedWidthInteger( + _ value: some FixedWidthInteger, + atKey key: Key + ) throws { + guard let validatedValue = Int(exactly: value) else { + throw URIValueToNodeEncoder.GeneralError.integerOutOfRange + } + try _insertValue(.integer(validatedValue), atKey: key) + } +} + +extension URIKeyedEncodingContainer: KeyedEncodingContainerProtocol { + + var codingPath: [any CodingKey] { + encoder.codingPath + } + + mutating func encodeNil(forKey key: Key) throws { + // Setting a nil value is equivalent to not encoding the value at all. + } + + mutating func encode(_ value: Bool, forKey key: Key) throws { + try _insertValue(.bool(value), atKey: key) + } + + mutating func encode(_ value: String, forKey key: Key) throws { + try _insertValue(.string(value), atKey: key) + } + + mutating func encode(_ value: Double, forKey key: Key) throws { + try _insertBinaryFloatingPoint(value, atKey: key) + } + + mutating func encode(_ value: Float, forKey key: Key) throws { + try _insertBinaryFloatingPoint(value, atKey: key) + } + + mutating func encode(_ value: Int, forKey key: Key) throws { + try _insertFixedWidthInteger(value, atKey: key) + } + + mutating func encode(_ value: Int8, forKey key: Key) throws { + try _insertFixedWidthInteger(value, atKey: key) + } + + mutating func encode(_ value: Int16, forKey key: Key) throws { + try _insertFixedWidthInteger(value, atKey: key) + } + + mutating func encode(_ value: Int32, forKey key: Key) throws { + try _insertFixedWidthInteger(value, atKey: key) + } + + mutating func encode(_ value: Int64, forKey key: Key) throws { + try _insertFixedWidthInteger(value, atKey: key) + } + + mutating func encode(_ value: UInt, forKey key: Key) throws { + try _insertFixedWidthInteger(value, atKey: key) + } + + mutating func encode(_ value: UInt8, forKey key: Key) throws { + try _insertFixedWidthInteger(value, atKey: key) + } + + mutating func encode(_ value: UInt16, forKey key: Key) throws { + try _insertFixedWidthInteger(value, atKey: key) + } + + mutating func encode(_ value: UInt32, forKey key: Key) throws { + try _insertFixedWidthInteger(value, atKey: key) + } + + mutating func encode(_ value: UInt64, forKey key: Key) throws { + try _insertFixedWidthInteger(value, atKey: key) + } + + mutating func encode(_ value: T, forKey key: Key) throws where T: Encodable { + switch value { + case let value as UInt8: + try encode(value, forKey: key) + case let value as Int8: + try encode(value, forKey: key) + case let value as UInt16: + try encode(value, forKey: key) + case let value as Int16: + try encode(value, forKey: key) + case let value as UInt32: + try encode(value, forKey: key) + case let value as Int32: + try encode(value, forKey: key) + case let value as UInt64: + try encode(value, forKey: key) + case let value as Int64: + try encode(value, forKey: key) + case let value as Int: + try encode(value, forKey: key) + case let value as UInt: + try encode(value, forKey: key) + case let value as Float: + try encode(value, forKey: key) + case let value as Double: + try encode(value, forKey: key) + case let value as String: + try encode(value, forKey: key) + case let value as Bool: + try encode(value, forKey: key) + case let value as Date: + try _insertValue(.date(value), atKey: key) + default: + encoder.push(key: .init(key), newStorage: .unset) + try value.encode(to: encoder) + try encoder.pop() + } + } + + mutating func nestedContainer( + keyedBy keyType: NestedKey.Type, + forKey key: Key + ) -> KeyedEncodingContainer where NestedKey: CodingKey { + encoder.container(keyedBy: NestedKey.self) + } + + mutating func nestedUnkeyedContainer( + forKey key: Key + ) -> any UnkeyedEncodingContainer { + encoder.unkeyedContainer() + } + + mutating func superEncoder() -> any Encoder { + encoder + } + + mutating func superEncoder(forKey key: Key) -> any Encoder { + encoder + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Single.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Single.swift new file mode 100644 index 00000000..f661ca61 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Single.swift @@ -0,0 +1,150 @@ +//===----------------------------------------------------------------------===// +// +// 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 single value container used by `URIValueToNodeEncoder`. +struct URISingleValueEncodingContainer { + + /// The associated encoder. + let encoder: URIValueToNodeEncoder +} + +extension URISingleValueEncodingContainer { + + /// Sets the provided primitive value to the underlying node. + /// - Parameter node: The primitive value to set. + private func _setValue(_ node: URIEncodedNode.Primitive) throws { + try encoder.currentStackEntry.storage.set(node) + } + + /// Sets the provided value to the underlying node. + /// - Parameter node: The value to set. + private func _setBinaryFloatingPoint(_ value: some BinaryFloatingPoint) throws { + try _setValue(.double(Double(value))) + } + + /// Sets the provided value to the underlying node. + /// - Parameter node: The value to set. + private func _setFixedWidthInteger(_ value: some FixedWidthInteger) throws { + guard let validatedValue = Int(exactly: value) else { + throw URIValueToNodeEncoder.GeneralError.integerOutOfRange + } + try _setValue(.integer(validatedValue)) + } +} + +extension URISingleValueEncodingContainer: SingleValueEncodingContainer { + + var codingPath: [any CodingKey] { + encoder.codingPath + } + + func encodeNil() throws { + throw URIValueToNodeEncoder.GeneralError.nilNotSupported + } + + func encode(_ value: Bool) throws { + try _setValue(.bool(value)) + } + + func encode(_ value: String) throws { + try _setValue(.string(value)) + } + + func encode(_ value: Double) throws { + try _setBinaryFloatingPoint(value) + } + + func encode(_ value: Float) throws { + try _setBinaryFloatingPoint(value) + } + + func encode(_ value: Int) throws { + try _setFixedWidthInteger(value) + } + + func encode(_ value: Int8) throws { + try _setFixedWidthInteger(value) + } + + func encode(_ value: Int16) throws { + try _setFixedWidthInteger(value) + } + + func encode(_ value: Int32) throws { + try _setFixedWidthInteger(value) + } + + func encode(_ value: Int64) throws { + try _setFixedWidthInteger(value) + } + + func encode(_ value: UInt) throws { + try _setFixedWidthInteger(value) + } + + func encode(_ value: UInt8) throws { + try _setFixedWidthInteger(value) + } + + func encode(_ value: UInt16) throws { + try _setFixedWidthInteger(value) + } + + func encode(_ value: UInt32) throws { + try _setFixedWidthInteger(value) + } + + func encode(_ value: UInt64) throws { + try _setFixedWidthInteger(value) + } + + func encode(_ value: T) throws where T: Encodable { + switch value { + case let value as UInt8: + try encode(value) + case let value as Int8: + try encode(value) + case let value as UInt16: + try encode(value) + case let value as Int16: + try encode(value) + case let value as UInt32: + try encode(value) + case let value as Int32: + try encode(value) + case let value as UInt64: + try encode(value) + case let value as Int64: + try encode(value) + case let value as Int: + try encode(value) + case let value as UInt: + try encode(value) + case let value as Float: + try encode(value) + case let value as Double: + try encode(value) + case let value as String: + try encode(value) + case let value as Bool: + try encode(value) + case let value as Date: + try _setValue(.date(value)) + default: + throw URIValueToNodeEncoder.GeneralError.nestedValueInSingleValueContainer + } + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift new file mode 100644 index 00000000..ee63f135 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift @@ -0,0 +1,183 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// An unkeyed container used by `URIValueToNodeEncoder`. +struct URIUnkeyedEncodingContainer { + + /// The associated encoder. + let encoder: URIValueToNodeEncoder +} + +extension URIUnkeyedEncodingContainer { + + /// Appends the provided node to the underlying array. + /// - Parameter node: The node to append. + private func _appendValue(_ node: URIEncodedNode) throws { + try encoder.currentStackEntry.storage.append(node) + } + + /// Appends the provided primitive value as a node to the underlying array. + /// - Parameter node: The value to append. + private func _appendValue(_ node: URIEncodedNode.Primitive) throws { + try _appendValue(.primitive(node)) + } + + /// Appends the provided value as a node to the underlying array. + /// - Parameter node: The value to append. + private func _appendBinaryFloatingPoint(_ value: some BinaryFloatingPoint) throws { + try _appendValue(.double(Double(value))) + } + + /// Appends the provided value as a node to the underlying array. + /// - Parameter node: The value to append. + private func _appendFixedWidthInteger(_ value: some FixedWidthInteger) throws { + guard let validatedValue = Int(exactly: value) else { + throw URIValueToNodeEncoder.GeneralError.integerOutOfRange + } + try _appendValue(.integer(validatedValue)) + } +} + +extension URIUnkeyedEncodingContainer: UnkeyedEncodingContainer { + + var codingPath: [any CodingKey] { + encoder.codingPath + } + + var count: Int { + switch encoder.currentStackEntry.storage { + case .array(let array): + return array.count + case .unset: + return 0 + default: + fatalError("Cannot have an unkeyed container at \(encoder.currentStackEntry).") + } + } + + func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { + encoder.unkeyedContainer() + } + + func nestedContainer( + keyedBy keyType: NestedKey.Type + ) -> KeyedEncodingContainer where NestedKey: CodingKey { + encoder.container(keyedBy: NestedKey.self) + } + + func superEncoder() -> any Encoder { + encoder + } + + func encodeNil() throws { + throw URIValueToNodeEncoder.GeneralError.nilNotSupported + } + + func encode(_ value: Bool) throws { + try _appendValue(.bool(value)) + } + + func encode(_ value: String) throws { + try _appendValue(.string(value)) + } + + func encode(_ value: Double) throws { + try _appendBinaryFloatingPoint(value) + } + + func encode(_ value: Float) throws { + try _appendBinaryFloatingPoint(value) + } + + func encode(_ value: Int) throws { + try _appendFixedWidthInteger(value) + } + + func encode(_ value: Int8) throws { + try _appendFixedWidthInteger(value) + } + + func encode(_ value: Int16) throws { + try _appendFixedWidthInteger(value) + } + + func encode(_ value: Int32) throws { + try _appendFixedWidthInteger(value) + } + + func encode(_ value: Int64) throws { + try _appendFixedWidthInteger(value) + } + + func encode(_ value: UInt) throws { + try _appendFixedWidthInteger(value) + } + + func encode(_ value: UInt8) throws { + try _appendFixedWidthInteger(value) + } + + func encode(_ value: UInt16) throws { + try _appendFixedWidthInteger(value) + } + + func encode(_ value: UInt32) throws { + try _appendFixedWidthInteger(value) + } + + func encode(_ value: UInt64) throws { + try _appendFixedWidthInteger(value) + } + + func encode(_ value: T) throws where T: Encodable { + switch value { + case let value as UInt8: + try encode(value) + case let value as Int8: + try encode(value) + case let value as UInt16: + try encode(value) + case let value as Int16: + try encode(value) + case let value as UInt32: + try encode(value) + case let value as Int32: + try encode(value) + case let value as UInt64: + try encode(value) + case let value as Int64: + try encode(value) + case let value as Int: + try encode(value) + case let value as UInt: + try encode(value) + case let value as Float: + try encode(value) + case let value as Double: + try encode(value) + case let value as String: + try encode(value) + case let value as Bool: + try encode(value) + case let value as Date: + try _appendValue(.date(value)) + default: + encoder.push(key: .init(intValue: count), newStorage: .unset) + try value.encode(to: encoder) + try encoder.pop() + } + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder.swift new file mode 100644 index 00000000..919de179 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder.swift @@ -0,0 +1,146 @@ +//===----------------------------------------------------------------------===// +// +// 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 type that converts an `Encodable` type into a `URIEncodableNode` value. +final class URIValueToNodeEncoder { + + /// An entry in the coding stack for `URIEncoder`. + /// + /// This is used to keep track of where we are in the encode. + struct CodingStackEntry { + + /// The key at which to write the node. + var key: URICoderCodingKey + + /// The node at the key inside its parent. + var storage: URIEncodedNode + } + + /// An encoder error. + enum GeneralError: Swift.Error { + + /// The encoder set a nil value, which isn't supported. + case nilNotSupported + + /// The encoder set a Data value, which isn't supported. + case dataNotSupported + + /// The encoder set a value for an index out of range of the container. + case integerOutOfRange + + /// The encoder tried to treat + case nestedValueInSingleValueContainer + } + + /// The stack of nested values within the root node. + private var _codingPath: [CodingStackEntry] + + /// The current value, which will be added on top of the stack once + /// finished encoding. + var currentStackEntry: CodingStackEntry + + /// Creates a new encoder. + init() { + self._codingPath = [] + self.currentStackEntry = CodingStackEntry( + key: .init(stringValue: ""), + storage: .unset + ) + } + + /// Encodes the provided value into a node. + /// - Parameter value: The value to encode. + /// - Returns: The node with the encoded contents of the value. + func encodeValue(_ value: some Encodable) throws -> URIEncodedNode { + defer { + _codingPath = [] + currentStackEntry = CodingStackEntry( + key: .init(stringValue: ""), + storage: .unset + ) + } + + // We have to catch the special values early, otherwise we fall + // back to their Codable implementations, which don't give us + // a chance to customize the coding in the containers. + if let date = value as? Date { + var container = singleValueContainer() + try container.encode(date) + } else { + try value.encode(to: self) + } + + let encodedValue = currentStackEntry.storage + return encodedValue + } +} + +extension URIValueToNodeEncoder { + + /// Pushes a new container on top of the current stack, nesting into the + /// value at the provided key. + /// - Parameters: + /// - key: The coding key for the new value on top of the stack. + /// - newStorage: The node to push on top of the stack. + func push(key: URICoderCodingKey, newStorage: URIEncodedNode) { + _codingPath.append(currentStackEntry) + currentStackEntry = .init(key: key, storage: newStorage) + } + + /// Pops the top container from the stack and restores the previously top + /// container to be the current top container. + func pop() throws { + // This is called when we've completed the storage in the current container. + // We can pop the value at the base of the stack, then "insert" the current one + // into it, and save the new value as the new current. + let current = currentStackEntry + var newCurrent = _codingPath.removeLast() + try newCurrent.storage.insert(current.storage, atKey: current.key) + currentStackEntry = newCurrent + } +} + +extension URIValueToNodeEncoder: Encoder { + + var codingPath: [any CodingKey] { + // The coding path meaningful to the types conforming to Codable. + // 1. Omit the root coding path. + // 2. Add the current stack entry's coding path. + (_codingPath + .dropFirst() + .map(\.key) + + [currentStackEntry.key]) + .map { $0 as any CodingKey } + } + + var userInfo: [CodingUserInfoKey: Any] { + [:] + } + + func container( + keyedBy type: Key.Type + ) -> KeyedEncodingContainer where Key: CodingKey { + KeyedEncodingContainer(URIKeyedEncodingContainer(encoder: self)) + } + + func unkeyedContainer() -> any UnkeyedEncodingContainer { + URIUnkeyedEncodingContainer(encoder: self) + } + + func singleValueContainer() -> any SingleValueEncodingContainer { + URISingleValueEncodingContainer(encoder: self) + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift new file mode 100644 index 00000000..795acc6e --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift @@ -0,0 +1,356 @@ +//===----------------------------------------------------------------------===// +// +// 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 type that parses a `URIParsedNode` from a URI-encoded string. +struct URIParser: Sendable { + + /// The configuration instructing the parser how to interpret the raw + /// string. + private let configuration: URICoderConfiguration + + /// The underlying raw string storage. + private var data: Raw + + /// Creates a new parser. + /// - Parameters: + /// - configuration: The configuration instructing the parser how + /// to interpret the raw string. + /// - data: The string to parse. + init(configuration: URICoderConfiguration, data: String) { + self.configuration = configuration + self.data = data[...] + } +} + +/// A typealias for the underlying raw string storage. +private typealias Raw = String.SubSequence + +/// A parser error. +private enum ParsingError: Swift.Error { + + /// A malformed key-value pair was detected. + case malformedKeyValuePair(Raw) +} + +// 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. + 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 ["": [""]] + } + } + 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() + } + } + + /// Parses the root node assuming the raw string uses the form style + /// and the explode parameter is enabled. + /// - Returns: The parsed root node. + private mutating func parseExplodedFormRoot() throws -> URIParsedNode { + try parseGenericRoot { data, appendPair in + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + + 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() + } + 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. + private mutating func parseUnexplodedFormRoot() throws -> URIParsedNode { + try parseGenericRoot { data, appendPair in + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + let valueSeparator: Character = "," + + 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 + } + } + 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) + } + } + } + + /// Parses the root node assuming the raw string uses the simple style + /// and the explode parameter is enabled. + /// - Returns: The parsed root node. + private mutating func parseExplodedSimpleRoot() throws -> URIParsedNode { + try parseGenericRoot { data, appendPair in + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "," + + 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 value. + key = .init() + value = 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. + 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 + let pairSeparator: Character = "," + while !data.isEmpty { + let value = data.parseUpToCharacterOrEnd( + pairSeparator + ) + appendPair(.init(), [value]) + } + } + } +} + +// MARK: - URIParser utilities + +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. + 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. + private func unescapeValue(_ escapedValue: Raw) -> Raw { + Self.unescapeValue( + escapedValue, + spaceEscapingCharacter: configuration.spaceEscapingCharacter + ) + } + + /// Removes escaping from the provided string. + /// - Parameters: + /// - escapedValue: An escaped string. + /// - spaceEscapingCharacter: The character used to escape the space + /// character. + /// - Returns: The provided string with escaping removed. + private static func unescapeValue( + _ escapedValue: Raw, + spaceEscapingCharacter: URICoderConfiguration.SpaceEscapingCharacter + ) -> Raw { + // The inverse of URISerializer.computeSafeString. + let partiallyDecoded = escapedValue.replacingOccurrences( + of: spaceEscapingCharacter.rawValue, + with: " " + ) + return (partiallyDecoded.removingPercentEncoding ?? "")[...] + } +} + +// MARK: - Substring utilities + +extension String.SubSequence { + + /// A result of calling `parseUpToEitherCharacterOrEnd`. + fileprivate enum ParseUpToEitherCharacterResult { + + /// The first character was detected. + case foundFirst + + /// The second character was detected, or the end was reached. + case foundSecondOrEnd + } + + /// Accumulates characters until one of the parameter characters is found, + /// or the end is reached. Moves the underlying startIndex. + /// - Parameters: + /// - first: A character to stop at. + /// - second: Another character to stop at. + /// - Returns: A result indicating which character was detected, if any, and + /// the accumulated substring. + fileprivate mutating func parseUpToEitherCharacterOrEnd( + first: Character, + second: Character + ) -> (ParseUpToEitherCharacterResult, Self) { + let startIndex = startIndex + guard startIndex != endIndex else { + return (.foundSecondOrEnd, .init()) + } + var currentIndex = startIndex + + func finalize( + _ result: ParseUpToEitherCharacterResult + ) -> (ParseUpToEitherCharacterResult, Self) { + let parsed = self[startIndex.. Self { + let startIndex = startIndex + guard startIndex != endIndex else { + return .init() + } + var currentIndex = startIndex + + func finalize() -> Self { + let parsed = self[startIndex.. String { + defer { + data.removeAll(keepingCapacity: true) + } + try serializeTopLevelNode(value, forKey: key) + return data + } +} + +extension CharacterSet { + + /// A character set of unreserved symbols only from RFC 6570 (excludes + /// alphanumeric characters). + fileprivate static let unreservedSymbols: CharacterSet = .init(charactersIn: "-._~") + + /// A character set of unreserved characters from RFC 6570. + fileprivate static let unreserved: CharacterSet = .alphanumerics.union(unreservedSymbols) + + /// A character set with only the space character. + fileprivate static let space: CharacterSet = .init(charactersIn: " ") + + /// A character set of unreserved characters and a space. + fileprivate static let unreservedAndSpace: CharacterSet = .unreserved.union(space) +} + +extension URISerializer { + + /// A serializer error. + private enum SerializationError: Swift.Error { + + /// Nested containers are not supported. + case nestedContainersNotSupported + } + + /// Computes an escaped version of the provided string. + /// - Parameter unsafeString: A string that needs escaping. + /// - Returns: The provided string with percent-escaping applied. + private func computeSafeString(_ unsafeString: String) -> String { + // The space character needs to be encoded based on the config, + // so first allow it to be unescaped, and then we'll do a second + // pass and only encode the space based on the config. + let partiallyEncoded = + unsafeString.addingPercentEncoding( + withAllowedCharacters: .unreservedAndSpace + ) ?? "" + let fullyEncoded = partiallyEncoded.replacingOccurrences( + of: " ", + with: configuration.spaceEscapingCharacter.rawValue + ) + return fullyEncoded + } + + /// Provides a raw string value for the provided key. + /// - Parameter key: The key to stringify. + /// - Returns: The escaped version of the provided key. + private func stringifiedKey(_ key: String) throws -> String { + // The root key is handled separately. + guard !key.isEmpty else { + return "" + } + let safeTopLevelKey = computeSafeString(key) + return safeTopLevelKey + } + + /// Serializes the provided value into the underlying string. + /// - Parameters: + /// - value: The value to serialize. + /// - key: The key to serialize the value under (details depend on the + /// style and explode parameters in the configuration). + private mutating func serializeTopLevelNode( + _ value: URIEncodedNode, + forKey key: String + ) throws { + func unwrapPrimitiveValue(_ node: URIEncodedNode) throws -> URIEncodedNode.Primitive { + guard case let .primitive(primitive) = node else { + throw SerializationError.nestedContainersNotSupported + } + return primitive + } + switch value { + case .unset: + // Nothing to serialize. + break + case .primitive(let primitive): + let keyAndValueSeparator: String? + switch configuration.style { + case .form: + keyAndValueSeparator = "=" + case .simple: + keyAndValueSeparator = nil + } + try serializePrimitiveKeyValuePair( + primitive, + forKey: key, + separator: keyAndValueSeparator + ) + case .array(let array): + try serializeArray( + array.map(unwrapPrimitiveValue), + forKey: key + ) + case .dictionary(let dictionary): + try serializeDictionary( + dictionary.mapValues(unwrapPrimitiveValue), + forKey: key + ) + } + } + + /// Serializes the provided value into the underlying string. + /// - Parameter value: The primitive value to serialize. + private mutating func serializePrimitiveValue( + _ value: URIEncodedNode.Primitive + ) throws { + let stringValue: String + switch value { + case .bool(let bool): + stringValue = bool.description + case .string(let string): + stringValue = computeSafeString(string) + case .integer(let int): + stringValue = int.description + case .double(let double): + stringValue = double.description + case .date(let date): + stringValue = try computeSafeString(configuration.dateTranscoder.encode(date)) + } + data.append(stringValue) + } + + /// Serializes the provided key-value pair into the underlying string. + /// - Parameters: + /// - value: The value to serialize. + /// - key: The key to serialize the value under (details depend on the + /// style and explode parameters in the configuration). + /// - separator: The separator to use, if nil, the key is not serialized, + /// only the value. + private mutating func serializePrimitiveKeyValuePair( + _ value: URIEncodedNode.Primitive, + forKey key: String, + separator: String? + ) throws { + if let separator { + data.append(try stringifiedKey(key)) + data.append(separator) + } + try serializePrimitiveValue(value) + } + + /// Serializes the provided array into the underlying string. + /// - Parameters: + /// - array: The value to serialize. + /// - key: The key to serialize the value under (details depend on the + /// style and explode parameters in the configuration). + 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) { + case (.form, true): + keyAndValueSeparator = "=" + pairSeparator = "&" + case (.form, false): + keyAndValueSeparator = nil + pairSeparator = "," + case (.simple, _): + keyAndValueSeparator = nil + pairSeparator = "," + } + func serializeNext(_ element: URIEncodedNode.Primitive) throws { + if let keyAndValueSeparator { + try serializePrimitiveKeyValuePair( + element, + forKey: key, + separator: keyAndValueSeparator + ) + } else { + try serializePrimitiveValue(element) + } + } + if let containerKeyAndValue = configuration.containerKeyAndValueSeparator { + data.append(try stringifiedKey(key)) + data.append(containerKeyAndValue) + } + for element in array.dropLast() { + try serializeNext(element) + data.append(pairSeparator) + } + if let element = array.last { + try serializeNext(element) + } + } + + /// Serializes the provided dictionary into the underlying string. + /// - Parameters: + /// - dictionary: The value to serialize. + /// - key: The key to serialize the value under (details depend on the + /// style and explode parameters in the configuration). + private mutating func serializeDictionary( + _ dictionary: [String: URIEncodedNode.Primitive], + forKey key: String + ) throws { + guard !dictionary.isEmpty else { + return + } + let sortedDictionary = + dictionary + .sorted { a, b in + a.key.localizedCaseInsensitiveCompare(b.key) + == .orderedAscending + } + + let keyAndValueSeparator: String + let pairSeparator: String + switch (configuration.style, configuration.explode) { + case (.form, true): + keyAndValueSeparator = "=" + pairSeparator = "&" + case (.form, false): + keyAndValueSeparator = "," + pairSeparator = "," + case (.simple, true): + keyAndValueSeparator = "=" + pairSeparator = "," + case (.simple, false): + keyAndValueSeparator = "," + pairSeparator = "," + } + + func serializeNext(_ element: URIEncodedNode.Primitive, forKey elementKey: String) throws { + try serializePrimitiveKeyValuePair( + element, + forKey: elementKey, + separator: keyAndValueSeparator + ) + } + if let containerKeyAndValue = configuration.containerKeyAndValueSeparator { + data.append(try stringifiedKey(key)) + data.append(containerKeyAndValue) + } + for (elementKey, element) in sortedDictionary.dropLast() { + try serializeNext(element, forKey: elementKey) + data.append(pairSeparator) + } + if let (elementKey, element) = sortedDictionary.last { + try serializeNext(element, forKey: elementKey) + } + } +} + +extension URICoderConfiguration { + + /// Returns the separator of a key and value in a pair for + /// the configuration. Can be nil, in which case no key should be + /// serialized, only the value. + fileprivate var containerKeyAndValueSeparator: String? { + switch (style, explode) { + case (.form, false): + return "=" + default: + return nil + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift new file mode 100644 index 00000000..8304102c --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// +// 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 XCTest +@testable import OpenAPIRuntime + +final class Test_URIDecoder: Test_Runtime { + + func testDecoding() 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")) + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift new file mode 100644 index 00000000..8a67ac0e --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift @@ -0,0 +1,157 @@ +//===----------------------------------------------------------------------===// +// +// 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 XCTest +@testable import OpenAPIRuntime + +final class Test_URIValueFromNodeDecoder: Test_Runtime { + + func testDecoding() throws { + struct SimpleStruct: Decodable, Equatable { + var foo: String + var bar: Int? + var color: SimpleEnum? + } + + enum SimpleEnum: String, Decodable, Equatable { + case red + case green + case blue + } + + // An empty string. + try test( + ["root": [""]], + "", + key: "root" + ) + + // An empty string with a simple style. + try test( + ["root": [""]], + "", + key: "root", + style: .simple + ) + + // A string with a space. + try test( + ["root": ["Hello World"]], + "Hello World", + key: "root" + ) + + // An enum. + try test( + ["root": ["red"]], + SimpleEnum.red, + key: "root" + ) + + // An integer. + try test( + ["root": ["1234"]], + 1234, + key: "root" + ) + + // A float. + try test( + ["root": ["12.34"]], + 12.34, + key: "root" + ) + + // A bool. + try test( + ["root": ["true"]], + true, + key: "root" + ) + + // A simple array of strings. + try test( + ["root": ["a", "b", "c"]], + ["a", "b", "c"], + key: "root" + ) + + // A simple array of enums. + try test( + ["root": ["red", "green", "blue"]], + [.red, .green, .blue] as [SimpleEnum], + key: "root" + ) + + // A struct. + try test( + ["foo": ["bar"]], + SimpleStruct(foo: "bar"), + key: "root" + ) + + // A struct with a nested enum. + 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" + ) + + // A unexploded simple dictionary. + try test( + ["root": ["one", "1", "two", "2"]], + ["one": 1, "two": 2], + key: "root", + explode: false + ) + + // A dictionary of enums. + try test( + ["one": ["blue"], "two": ["green"]], + ["one": .blue, "two": .green] as [String: SimpleEnum], + key: "root" + ) + + func test( + _ node: URIParsedNode, + _ expectedValue: T, + key: String, + style: URICoderConfiguration.Style = .form, + explode: Bool = true, + file: StaticString = #file, + line: UInt = #line + ) throws { + let decoder = URIValueFromNodeDecoder( + node: node, + rootKey: key[...], + style: style, + explode: explode, + dateTranscoder: .iso8601 + ) + let decodedValue = try decoder.decodeRoot(T.self) + XCTAssertEqual( + decodedValue, + expectedValue, + file: file, + line: line + ) + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift new file mode 100644 index 00000000..4250db26 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// +// 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 XCTest +@testable import OpenAPIRuntime + +final class Test_URIEncoder: Test_Runtime { + + func testEncoding() throws { + struct Foo: Encodable { + var bar: String + } + let serializer = URISerializer(configuration: .formDataExplode) + let encoder = URIEncoder(serializer: serializer) + let encodedString = try encoder.encode( + Foo(bar: "hello world"), + forKey: "root" + ) + XCTAssertEqual(encodedString, "bar=hello+world") + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift new file mode 100644 index 00000000..d6967014 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift @@ -0,0 +1,270 @@ +//===----------------------------------------------------------------------===// +// +// 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 XCTest +@testable import OpenAPIRuntime + +final class Test_URIValueToNodeEncoder: Test_Runtime { + + func testEncoding() throws { + struct Case { + var value: any Encodable + var expectedNode: URIEncodedNode + var file: StaticString = #file + var line: UInt = #line + } + func makeCase( + _ value: any Encodable, + _ expectedNode: URIEncodedNode, + file: StaticString = #file, + line: UInt = #line + ) + -> Case + { + .init(value: value, expectedNode: expectedNode, file: file, line: line) + } + + enum SimpleEnum: String, Encodable { + case foo + case bar + } + + struct SimpleStruct: Encodable { + var foo: String + var bar: Int? + var val: SimpleEnum? + } + + struct NestedStruct: Encodable { + var simple: SimpleStruct + } + + let cases: [Case] = [ + + // An empty string. + makeCase( + "", + .primitive(.string("")) + ), + + // A string with a space. + makeCase( + "Hello World", + .primitive(.string("Hello World")) + ), + + // An integer. + makeCase( + 1234, + .primitive(.integer(1234)) + ), + + // A float. + makeCase( + 12.34, + .primitive(.double(12.34)) + ), + + // A bool. + makeCase( + true, + .primitive(.bool(true)) + ), + + // An enum. + makeCase( + SimpleEnum.foo, + .primitive(.string("foo")) + ), + + // A simple array of strings. + makeCase( + ["a", "b", "c"], + .array([ + .primitive(.string("a")), + .primitive(.string("b")), + .primitive(.string("c")), + ]) + ), + + // A simple array of enums. + makeCase( + [SimpleEnum.foo, SimpleEnum.bar], + .array([ + .primitive(.string("foo")), + .primitive(.string("bar")), + ]) + ), + + // A nested array. + makeCase( + [["a"], ["b", "c"]], + .array([ + .array([ + .primitive(.string("a")) + ]), + .array([ + .primitive(.string("b")), + .primitive(.string("c")), + ]), + ]) + ), + + // A struct. + makeCase( + SimpleStruct(foo: "bar", val: .foo), + .dictionary([ + "foo": .primitive(.string("bar")), + "val": .primitive(.string("foo")), + ]) + ), + + // A nested struct. + makeCase( + NestedStruct(simple: SimpleStruct(foo: "bar")), + .dictionary([ + "simple": .dictionary([ + "foo": .primitive(.string("bar")) + ]) + ]) + ), + + // An array of structs. + makeCase( + [ + SimpleStruct(foo: "bar"), + SimpleStruct(foo: "baz", val: .bar), + ], + .array([ + .dictionary([ + "foo": .primitive(.string("bar")) + ]), + .dictionary([ + "foo": .primitive(.string("baz")), + "val": .primitive(.string("bar")), + ]), + ]) + ), + + // An array of arrays of structs. + makeCase( + [ + [ + SimpleStruct(foo: "bar") + ], + [ + SimpleStruct(foo: "baz") + ], + ], + .array([ + .array([ + .dictionary([ + "foo": .primitive(.string("bar")) + ]) + ]), + .array([ + .dictionary([ + "foo": .primitive(.string("baz")) + ]) + ]), + ]) + ), + + // A simple dictionary of string -> int pairs. + makeCase( + ["one": 1, "two": 2], + .dictionary([ + "one": .primitive(.integer(1)), + "two": .primitive(.integer(2)), + ]) + ), + + // A simple dictionary of string -> enum pairs. + makeCase( + ["one": SimpleEnum.bar], + .dictionary([ + "one": .primitive(.string("bar")) + ]) + ), + + // A nested dictionary. + makeCase( + [ + "A": ["one": 1, "two": 2], + "B": ["three": 3, "four": 4], + ], + .dictionary([ + "A": .dictionary([ + "one": .primitive(.integer(1)), + "two": .primitive(.integer(2)), + ]), + "B": .dictionary([ + "three": .primitive(.integer(3)), + "four": .primitive(.integer(4)), + ]), + ]) + ), + + // A dictionary of structs. + makeCase( + [ + "barkey": SimpleStruct(foo: "bar"), + "bazkey": SimpleStruct(foo: "baz"), + ], + .dictionary([ + "barkey": .dictionary([ + "foo": .primitive(.string("bar")) + ]), + "bazkey": .dictionary([ + "foo": .primitive(.string("baz")) + ]), + ]) + ), + + // An dictionary of dictionaries of structs. + makeCase( + [ + "outBar": + [ + "inBar": SimpleStruct(foo: "bar") + ], + "outBaz": [ + "inBaz": SimpleStruct(foo: "baz") + ], + ], + .dictionary([ + "outBar": .dictionary([ + "inBar": .dictionary([ + "foo": .primitive(.string("bar")) + ]) + ]), + "outBaz": .dictionary([ + "inBaz": .dictionary([ + "foo": .primitive(.string("baz")) + ]) + ]), + ]) + ), + ] + let encoder = URIValueToNodeEncoder() + for testCase in cases { + let encodedNode = try encoder.encodeValue(testCase.value) + XCTAssertEqual( + encodedNode, + testCase.expectedNode, + file: testCase.file, + line: testCase.line + ) + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift new file mode 100644 index 00000000..8ae57d60 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift @@ -0,0 +1,230 @@ +//===----------------------------------------------------------------------===// +// +// 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 XCTest +@testable import OpenAPIRuntime + +final class Test_URIParser: Test_Runtime { + + let testedVariants: [URICoderConfiguration] = [ + .formExplode, + .formUnexplode, + .simpleExplode, + .simpleUnexplode, + .formDataExplode, + .formDataUnexplode, + ] + + func testParsing() throws { + let cases: [Case] = [ + makeCase( + .init( + formExplode: "empty=", + formUnexplode: "empty=", + simpleExplode: .custom("", value: ["": [""]]), + simpleUnexplode: .custom("", value: ["": [""]]), + formDataExplode: "empty=", + formDataUnexplode: "empty=" + ), + value: [ + "empty": [""] + ] + ), + makeCase( + .init( + formExplode: "", + formUnexplode: "", + simpleExplode: .custom("", value: ["": [""]]), + simpleUnexplode: .custom("", value: ["": [""]]), + formDataExplode: "", + formDataUnexplode: "" + ), + 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" + ), + 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" + ), + 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" + ), + value: [ + "list": ["red", "green", "blue"] + ] + ), + makeCase( + .init( + formExplode: "comma=%2C&dot=.&semi=%3B", + formUnexplode: .custom( + "keys=comma,%2C,dot,.,semi,%3B", + value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]] + ), + simpleExplode: "comma=%2C,dot=.,semi=%3B", + simpleUnexplode: .custom( + "comma,%2C,dot,.,semi,%3B", + value: ["": ["comma", ",", "dot", ".", "semi", ";"]] + ), + formDataExplode: "comma=%2C&dot=.&semi=%3B", + formDataUnexplode: .custom( + "keys=comma,%2C,dot,.,semi,%3B", + value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]] + ) + ), + value: [ + "semi": [";"], + "dot": ["."], + "comma": [","], + ] + ), + ] + for testCase in cases { + func testVariant( + _ variant: Case.Variant, + _ input: Case.Variants.Input + ) throws { + var parser = URIParser( + configuration: variant.config, + data: input.string + ) + let parsedNode = try parser.parseRoot() + XCTAssertEqual( + parsedNode, + input.valueOverride ?? testCase.value, + "Failed for config: \(variant.name)", + file: testCase.file, + line: testCase.line + ) + } + 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) + } + } +} + +extension Test_URIParser { + struct Case { + struct Variant { + var name: String + var config: URICoderConfiguration + + static let formExplode: Self = .init( + name: "formExplode", + config: .formExplode + ) + static let formUnexplode: Self = .init( + name: "formUnexplode", + config: .formUnexplode + ) + static let simpleExplode: Self = .init( + name: "simpleExplode", + config: .simpleExplode + ) + static let simpleUnexplode: Self = .init( + name: "simpleUnexplode", + config: .simpleUnexplode + ) + static let formDataExplode: Self = .init( + name: "formDataExplode", + config: .formDataExplode + ) + static let formDataUnexplode: Self = .init( + name: "formDataUnexplode", + config: .formDataUnexplode + ) + } + struct Variants { + + struct Input: ExpressibleByStringLiteral { + var string: String + var valueOverride: URIParsedNode? + + init(string: String, valueOverride: URIParsedNode? = nil) { + self.string = string + self.valueOverride = valueOverride + } + + static func custom(_ string: String, value: URIParsedNode) -> Self { + .init(string: string, valueOverride: value) + } + + init(stringLiteral value: String) { + self.string = value + self.valueOverride = nil + } + } + + var formExplode: Input + var formUnexplode: Input + var simpleExplode: Input + var simpleUnexplode: Input + var formDataExplode: Input + var formDataUnexplode: 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 + ) + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift new file mode 100644 index 00000000..1e25109b --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift @@ -0,0 +1,220 @@ +//===----------------------------------------------------------------------===// +// +// 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 XCTest +@testable import OpenAPIRuntime + +final class Test_URISerializer: Test_Runtime { + + let testedVariants: [URICoderConfiguration] = [ + .formExplode, + .formUnexplode, + .simpleExplode, + .simpleUnexplode, + .formDataExplode, + .formDataUnexplode, + ] + + func testSerializing() throws { + let cases: [Case] = [ + makeCase( + value: .primitive(.string("")), + key: "empty", + .init( + formExplode: "empty=", + formUnexplode: "empty=", + simpleExplode: "", + simpleUnexplode: "", + formDataExplode: "empty=", + formDataUnexplode: "empty=" + ) + ), + makeCase( + value: .primitive(.string("fred")), + key: "who", + .init( + formExplode: "who=fred", + formUnexplode: "who=fred", + simpleExplode: "fred", + simpleUnexplode: "fred", + formDataExplode: "who=fred", + formDataUnexplode: "who=fred" + ) + ), + makeCase( + value: .primitive(.integer(1234)), + key: "x", + .init( + formExplode: "x=1234", + formUnexplode: "x=1234", + simpleExplode: "1234", + simpleUnexplode: "1234", + formDataExplode: "x=1234", + formDataUnexplode: "x=1234" + ) + ), + makeCase( + value: .primitive(.double(12.34)), + key: "x", + .init( + formExplode: "x=12.34", + formUnexplode: "x=12.34", + simpleExplode: "12.34", + simpleUnexplode: "12.34", + formDataExplode: "x=12.34", + formDataUnexplode: "x=12.34" + ) + ), + makeCase( + value: .primitive(.bool(true)), + key: "enabled", + .init( + formExplode: "enabled=true", + formUnexplode: "enabled=true", + simpleExplode: "true", + simpleUnexplode: "true", + formDataExplode: "enabled=true", + formDataUnexplode: "enabled=true" + ) + ), + makeCase( + value: .primitive(.string("Hello World")), + key: "hello", + .init( + formExplode: "hello=Hello%20World", + formUnexplode: "hello=Hello%20World", + simpleExplode: "Hello%20World", + simpleUnexplode: "Hello%20World", + formDataExplode: "hello=Hello+World", + formDataUnexplode: "hello=Hello+World" + ) + ), + makeCase( + value: .array([ + .primitive(.string("red")), + .primitive(.string("green")), + .primitive(.string("blue")), + ]), + key: "list", + .init( + formExplode: "list=red&list=green&list=blue", + formUnexplode: "list=red,green,blue", + simpleExplode: "red,green,blue", + simpleUnexplode: "red,green,blue", + formDataExplode: "list=red&list=green&list=blue", + formDataUnexplode: "list=red,green,blue" + ) + ), + makeCase( + value: .dictionary([ + "semi": .primitive(.string(";")), + "dot": .primitive(.string(".")), + "comma": .primitive(.string(",")), + ]), + key: "keys", + .init( + formExplode: "comma=%2C&dot=.&semi=%3B", + formUnexplode: "keys=comma,%2C,dot,.,semi,%3B", + simpleExplode: "comma=%2C,dot=.,semi=%3B", + simpleUnexplode: "comma,%2C,dot,.,semi,%3B", + formDataExplode: "comma=%2C&dot=.&semi=%3B", + formDataUnexplode: "keys=comma,%2C,dot,.,semi,%3B" + ) + ), + ] + for testCase in cases { + func testVariant(_ variant: Case.Variant, _ expectedString: String) throws { + var serializer = URISerializer(configuration: variant.config) + let encodedString = try serializer.serializeNode( + testCase.value, + forKey: testCase.key + ) + XCTAssertEqual( + encodedString, + expectedString, + "Failed for config: \(variant.name)", + file: testCase.file, + line: testCase.line + ) + } + try testVariant(.formExplode, testCase.variants.formExplode) + try testVariant(.formUnexplode, testCase.variants.formUnexplode) + try testVariant(.simpleExplode, testCase.variants.simpleExplode) + try testVariant(.simpleUnexplode, testCase.variants.simpleUnexplode) + try testVariant(.formDataExplode, testCase.variants.formDataExplode) + try testVariant(.formDataUnexplode, testCase.variants.formDataUnexplode) + } + } +} + +extension Test_URISerializer { + struct Case { + struct Variant { + var name: String + var config: URICoderConfiguration + + static let formExplode: Self = .init( + name: "formExplode", + config: .formExplode + ) + static let formUnexplode: Self = .init( + name: "formUnexplode", + config: .formUnexplode + ) + static let simpleExplode: Self = .init( + name: "simpleExplode", + config: .simpleExplode + ) + static let simpleUnexplode: Self = .init( + name: "simpleUnexplode", + config: .simpleUnexplode + ) + static let formDataExplode: Self = .init( + name: "formDataExplode", + config: .formDataExplode + ) + static let formDataUnexplode: Self = .init( + name: "formDataUnexplode", + config: .formDataUnexplode + ) + } + struct Variants { + var formExplode: String + var formUnexplode: String + var simpleExplode: String + var simpleUnexplode: String + var formDataExplode: String + var formDataUnexplode: String + } + var value: URIEncodedNode + var key: String + var variants: Variants + var file: StaticString = #file + var line: UInt = #line + } + func makeCase( + value: URIEncodedNode, + key: String, + _ variants: Case.Variants, + file: StaticString = #file, + line: UInt = #line + ) -> Case { + .init( + value: value, + key: key, + variants: variants, + file: file, + line: line + ) + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift new file mode 100644 index 00000000..77f0eb02 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift @@ -0,0 +1,380 @@ +//===----------------------------------------------------------------------===// +// +// 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 XCTest +@testable import OpenAPIRuntime + +final class Test_URICodingRoundtrip: Test_Runtime { + + func testRoundtrip() throws { + + struct SimpleStruct: Codable, Equatable { + var foo: String + var bar: Int + var color: SimpleEnum + var empty: String + var date: Date + } + + enum SimpleEnum: String, Codable, Equatable { + case red + case green + case blue + } + + // An empty string. + try _test( + "", + key: "root", + .init( + formExplode: "root=", + formUnexplode: "root=", + simpleExplode: "", + simpleUnexplode: "", + formDataExplode: "root=", + formDataUnexplode: "root=" + ) + ) + + // An string with a space. + try _test( + "Hello World!", + key: "root", + .init( + formExplode: "root=Hello%20World%21", + formUnexplode: "root=Hello%20World%21", + simpleExplode: "Hello%20World%21", + simpleUnexplode: "Hello%20World%21", + formDataExplode: "root=Hello+World%21", + formDataUnexplode: "root=Hello+World%21" + ) + ) + + // An enum. + try _test( + SimpleEnum.red, + key: "root", + .init( + formExplode: "root=red", + formUnexplode: "root=red", + simpleExplode: "red", + simpleUnexplode: "red", + formDataExplode: "root=red", + formDataUnexplode: "root=red" + ) + ) + + // An integer. + try _test( + 1234, + key: "root", + .init( + formExplode: "root=1234", + formUnexplode: "root=1234", + simpleExplode: "1234", + simpleUnexplode: "1234", + formDataExplode: "root=1234", + formDataUnexplode: "root=1234" + ) + ) + + // A float. + try _test( + 12.34, + key: "root", + .init( + formExplode: "root=12.34", + formUnexplode: "root=12.34", + simpleExplode: "12.34", + simpleUnexplode: "12.34", + formDataExplode: "root=12.34", + formDataUnexplode: "root=12.34" + ) + ) + + // A bool. + try _test( + true, + key: "root", + .init( + formExplode: "root=true", + formUnexplode: "root=true", + simpleExplode: "true", + simpleUnexplode: "true", + formDataExplode: "root=true", + formDataUnexplode: "root=true" + ) + ) + + // A Date. + try _test( + Date(timeIntervalSince1970: 1_692_948_899), + key: "root", + .init( + formExplode: "root=2023-08-25T07%3A34%3A59Z", + formUnexplode: "root=2023-08-25T07%3A34%3A59Z", + simpleExplode: "2023-08-25T07%3A34%3A59Z", + simpleUnexplode: "2023-08-25T07%3A34%3A59Z", + formDataExplode: "root=2023-08-25T07%3A34%3A59Z", + formDataUnexplode: "root=2023-08-25T07%3A34%3A59Z" + ) + ) + + // A simple array of strings. + try _test( + ["a", "b", "c"], + key: "list", + .init( + formExplode: "list=a&list=b&list=c", + formUnexplode: "list=a,b,c", + simpleExplode: "a,b,c", + simpleUnexplode: "a,b,c", + formDataExplode: "list=a&list=b&list=c", + formDataUnexplode: "list=a,b,c" + ) + ) + + // A simple array of dates. + try _test( + [ + Date(timeIntervalSince1970: 1_692_948_899), + Date(timeIntervalSince1970: 1_692_948_901), + ], + key: "list", + .init( + formExplode: "list=2023-08-25T07%3A34%3A59Z&list=2023-08-25T07%3A35%3A01Z", + formUnexplode: "list=2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z", + simpleExplode: "2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z", + 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" + ) + ) + + // An empty array of strings. + try _test( + [] as [String], + key: "list", + .init( + formExplode: "", + formUnexplode: "", + simpleExplode: .custom("", value: [""]), + simpleUnexplode: .custom("", value: [""]), + formDataExplode: "", + formDataUnexplode: "" + ) + ) + + // A simple array of enums. + try _test( + [.red, .green, .blue] as [SimpleEnum], + key: "list", + .init( + formExplode: "list=red&list=green&list=blue", + formUnexplode: "list=red,green,blue", + simpleExplode: "red,green,blue", + simpleUnexplode: "red,green,blue", + formDataExplode: "list=red&list=green&list=blue", + formDataUnexplode: "list=red,green,blue" + ) + ) + + // A struct. + try _test( + SimpleStruct( + foo: "hi!", + bar: 24, + color: .red, + empty: "", + date: Date(timeIntervalSince1970: 1_692_948_899) + ), + key: "keys", + .init( + formExplode: "bar=24&color=red&date=2023-08-25T07%3A34%3A59Z&empty=&foo=hi%21", + formUnexplode: "keys=bar,24,color,red,date,2023-08-25T07%3A34%3A59Z,empty,,foo,hi%21", + simpleExplode: "bar=24,color=red,date=2023-08-25T07%3A34%3A59Z,empty=,foo=hi%21", + simpleUnexplode: "bar,24,color,red,date,2023-08-25T07%3A34%3A59Z,empty,,foo,hi%21", + formDataExplode: "bar=24&color=red&date=2023-08-25T07%3A34%3A59Z&empty=&foo=hi%21", + formDataUnexplode: "keys=bar,24,color,red,date,2023-08-25T07%3A34%3A59Z,empty,,foo,hi%21" + ) + ) + + // An empty struct. + struct EmptyStruct: Codable, Equatable {} + try _test( + EmptyStruct(), + key: "keys", + .init( + formExplode: "", + formUnexplode: "", + simpleExplode: "", + simpleUnexplode: "", + formDataExplode: "", + formDataUnexplode: "" + ) + ) + + // A simple dictionary. + try _test( + ["foo": "hi!", "bar": "24", "color": "red", "empty": ""], + key: "keys", + .init( + formExplode: "bar=24&color=red&empty=&foo=hi%21", + formUnexplode: "keys=bar,24,color,red,empty,,foo,hi%21", + simpleExplode: "bar=24,color=red,empty=,foo=hi%21", + simpleUnexplode: "bar,24,color,red,empty,,foo,hi%21", + formDataExplode: "bar=24&color=red&empty=&foo=hi%21", + formDataUnexplode: "keys=bar,24,color,red,empty,,foo,hi%21" + ) + ) + + // An empty dictionary. + try _test( + [:] as [String: String], + key: "keys", + .init( + formExplode: "", + formUnexplode: "", + simpleExplode: .custom("", value: ["": ""]), + simpleUnexplode: .custom("", value: ["": ""]), + formDataExplode: "", + formDataUnexplode: "" + ) + ) + } + + struct Variant { + var name: String + var configuration: URICoderConfiguration + + static let formExplode: Self = .init( + name: "formExplode", + configuration: .formExplode + ) + static let formUnexplode: Self = .init( + name: "formUnexplode", + configuration: .formUnexplode + ) + static let simpleExplode: Self = .init( + name: "simpleExplode", + configuration: .simpleExplode + ) + static let simpleUnexplode: Self = .init( + name: "simpleUnexplode", + configuration: .simpleUnexplode + ) + static let formDataExplode: Self = .init( + name: "formDataExplode", + configuration: .formDataExplode + ) + static let formDataUnexplode: Self = .init( + name: "formDataUnexplode", + configuration: .formDataUnexplode + ) + } + struct Variants { + + struct Input: ExpressibleByStringLiteral { + var string: String + var customValue: T? + + init(string: String, customValue: T?) { + self.string = string + self.customValue = customValue + } + + init(stringLiteral value: String) { + self.init(string: value, customValue: nil) + } + + static func custom(_ string: String, value: T) -> Self { + .init(string: string, customValue: value) + } + } + + var formExplode: Input + var formUnexplode: Input + var simpleExplode: Input + var simpleUnexplode: Input + var formDataExplode: Input + var formDataUnexplode: Input + } + + func _test( + _ value: T, + key: String, + _ variants: Variants, + file: StaticString = #file, + line: UInt = #line + ) throws { + func testVariant( + name: String, + configuration: URICoderConfiguration, + variant: Variants.Input + ) throws { + let encoder = URIEncoder(configuration: configuration) + let encodedString = try encoder.encode(value, forKey: key) + XCTAssertEqual( + encodedString, + variant.string, + "Variant: \(name)", + file: file, + line: line + ) + let decoder = URIDecoder(configuration: configuration) + let decodedValue = try decoder.decode( + T.self, + forKey: key, + from: encodedString + ) + XCTAssertEqual( + decodedValue, + variant.customValue ?? value, + "Variant: \(name)", + file: file, + line: line + ) + } + try testVariant( + name: "formExplode", + configuration: .formExplode, + variant: variants.formExplode + ) + try testVariant( + name: "formUnexplode", + configuration: .formUnexplode, + variant: variants.formUnexplode + ) + try testVariant( + name: "simpleExplode", + configuration: .simpleExplode, + variant: variants.simpleExplode + ) + try testVariant( + name: "simpleUnexplode", + configuration: .simpleUnexplode, + variant: variants.simpleUnexplode + ) + try testVariant( + name: "formDataExplode", + configuration: .formDataExplode, + variant: variants.formDataExplode + ) + try testVariant( + name: "formDataUnexplode", + configuration: .formDataUnexplode, + variant: variants.formDataUnexplode + ) + } + +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift b/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift new file mode 100644 index 00000000..375c266a --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +@testable import OpenAPIRuntime + +extension URICoderConfiguration { + + private static let defaultDateTranscoder: any DateTranscoder = .iso8601 + + static let formExplode: Self = .init( + style: .form, + explode: true, + spaceEscapingCharacter: .percentEncoded, + dateTranscoder: defaultDateTranscoder + ) + + static let formUnexplode: Self = .init( + style: .form, + explode: false, + spaceEscapingCharacter: .percentEncoded, + dateTranscoder: defaultDateTranscoder + ) + + static let simpleExplode: Self = .init( + style: .simple, + explode: true, + spaceEscapingCharacter: .percentEncoded, + dateTranscoder: defaultDateTranscoder + ) + + static let simpleUnexplode: Self = .init( + style: .simple, + explode: false, + spaceEscapingCharacter: .percentEncoded, + dateTranscoder: defaultDateTranscoder + ) + + static let formDataExplode: Self = .init( + style: .form, + explode: true, + spaceEscapingCharacter: .plus, + dateTranscoder: defaultDateTranscoder + ) + + static let formDataUnexplode: Self = .init( + style: .form, + explode: false, + spaceEscapingCharacter: .plus, + dateTranscoder: defaultDateTranscoder + ) +}