From 0c24beb74a34eb524a87d0101e80d59d9260422b Mon Sep 17 00:00:00 2001 From: Ayush Thakur Date: Tue, 26 May 2026 19:46:59 +0530 Subject: [PATCH] Add validating setters to OpenAPI dynamic value containers Resolves apple/swift-openapi-generator#782 `OpenAPIValueContainer`, `OpenAPIObjectContainer`, and `OpenAPIArrayContainer` exposed `var value` as a stored property whose setter accepted any input, including types the public initializers would reject. That made it possible to construct a "validated" container whose contents were never validated: var c = OpenAPIObjectContainer() c.value["bad"] = BadGuy() Per the design discussion on #782 (czechboy0, weissi, simonjbeaumont), this change: - Converts each container's `value` to a computed property backed by a private stored property, keeping read access public and unchanged. - Marks only the setter as `@available(*, deprecated, message:)` so existing direct assignment still compiles with a deprecation warning but is steered toward the new API. - Adds `mutating func setValue(validating:)` that runs the same validation as `init(unvalidatedValue:)` and leaves the existing value intact on failure. Source compatibility is preserved for all public clients: reading `value` is unchanged, writing `value` still compiles but emits a deprecation warning. ### Motivation The original setter let validated containers drift out of a valid state. SwiftPM-generated code passes user data through these containers, so silent acceptance of unsupported types broke the validation contract. ### Modifications - `Sources/OpenAPIRuntime/Base/OpenAPIValue.swift`: same change applied to all three containers (`OpenAPIValueContainer`, `OpenAPIObjectContainer`, `OpenAPIArrayContainer`). - `Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift`: six new tests covering the accept/reject paths for each container and verifying the previous value is unchanged on a failed validation. ### Result Users get a validated path to update container contents and a deprecation warning when they use the old direct-assignment path. All 27 existing and new tests pass locally. --- .../OpenAPIRuntime/Base/OpenAPIValue.swift | 67 +++++++++++++++-- .../Base/Test_OpenAPIValue.swift | 71 +++++++++++++++++++ 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift index 0ed8ff1..ee69249 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift @@ -45,13 +45,32 @@ import CoreFoundation /// Define the structure of your types in the OpenAPI document instead. public struct OpenAPIValueContainer: Codable, Hashable, Sendable { + private var _value: (any Sendable)? + /// The underlying dynamic value. - public var value: (any Sendable)? + public var value: (any Sendable)? { + get { _value } + @available( + *, + deprecated, + message: "Setting `value` directly does not validate the contents. Use `setValue(validating:)` instead." + ) + set { _value = newValue } + } + + /// Replaces the contained value with the given value after validating that + /// it consists of supported types. + /// - Parameter newValue: A value of a JSON-compatible type, such as + /// `String`, `[Any]`, and `[String: Any]`. + /// - Throws: When the value is not supported. + public mutating func setValue(validating newValue: (any Sendable)?) throws { + self._value = try Self.tryCast(newValue) + } /// Creates a new container with the given validated value. /// - Parameter value: A value of a JSON-compatible type, such as `String`, /// `[Any]`, and `[String: Any]`. - init(validatedValue value: (any Sendable)?) { self.value = value } + init(validatedValue value: (any Sendable)?) { self._value = value } /// Creates a new container with the given unvalidated value. /// @@ -349,12 +368,30 @@ extension OpenAPIValueContainer: ExpressibleByFloatLiteral { /// Define the structure of your types in the OpenAPI document instead. public struct OpenAPIObjectContainer: Codable, Hashable, Sendable { + private var _value: [String: (any Sendable)?] + /// The underlying dynamic dictionary value. - public var value: [String: (any Sendable)?] + public var value: [String: (any Sendable)?] { + get { _value } + @available( + *, + deprecated, + message: "Setting `value` directly does not validate the contents. Use `setValue(validating:)` instead." + ) + set { _value = newValue } + } + + /// Replaces the contained dictionary with the given dictionary after + /// validating that all of its values consist of supported types. + /// - Parameter newValue: A dictionary with values of JSON-compatible types. + /// - Throws: When the value is not supported. + public mutating func setValue(validating newValue: [String: (any Sendable)?]) throws { + self._value = try Self.tryCast(newValue) + } /// Creates a new container with the given validated dictionary. /// - Parameter value: A dictionary value. - init(validatedValue value: [String: (any Sendable)?]) { self.value = value } + init(validatedValue value: [String: (any Sendable)?]) { self._value = value } /// Creates a new empty container. public init() { self.init(validatedValue: [:]) } @@ -452,12 +489,30 @@ public struct OpenAPIObjectContainer: Codable, Hashable, Sendable { /// Define the structure of your types in the OpenAPI document instead. public struct OpenAPIArrayContainer: Codable, Hashable, Sendable { + private var _value: [(any Sendable)?] + /// The underlying dynamic array value. - public var value: [(any Sendable)?] + public var value: [(any Sendable)?] { + get { _value } + @available( + *, + deprecated, + message: "Setting `value` directly does not validate the contents. Use `setValue(validating:)` instead." + ) + set { _value = newValue } + } + + /// Replaces the contained array with the given array after validating that + /// all of its elements consist of supported types. + /// - Parameter newValue: An array with values of JSON-compatible types. + /// - Throws: When the value is not supported. + public mutating func setValue(validating newValue: [(any Sendable)?]) throws { + self._value = try Self.tryCast(newValue) + } /// Creates a new container with the given validated array. /// - Parameter value: An array value. - init(validatedValue value: [(any Sendable)?]) { self.value = value } + init(validatedValue value: [(any Sendable)?]) { self._value = value } /// Creates a new empty container. public init() { self.init(validatedValue: []) } diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index fb4b51e..1f9ce03 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -43,6 +43,77 @@ final class Test_OpenAPIValue: Test_Runtime { _ = try OpenAPIArrayContainer(unvalidatedValue: ["hello", ["nestedHello", 2] as [any Sendable]]) } + func testSetValueValidating_valueContainer_acceptsSupportedValues() throws { + var container = try OpenAPIValueContainer() + try container.setValue(validating: "hello") + XCTAssertEqual(container.value as? String, "hello") + try container.setValue(validating: 42) + XCTAssertEqual(container.value as? Int, 42) + try container.setValue(validating: ["nested": 1] as [String: any Sendable]) + let dict = try XCTUnwrap(container.value as? [String: Int]) + XCTAssertEqual(dict, ["nested": 1]) + } + + func testSetValueValidating_valueContainer_rejectsUnsupportedValue() throws { + struct Foobar: Sendable {} + var container = try OpenAPIValueContainer(unvalidatedValue: "seed") + XCTAssertThrowsError(try container.setValue(validating: Foobar())) { error in + guard case .invalidValue = error as? EncodingError else { + XCTFail("Unexpected error: \(error)") + return + } + } + XCTAssertEqual(container.value as? String, "seed", "value must be unchanged after a failed setValue(validating:)") + } + + func testSetValueValidating_objectContainer_acceptsSupportedValues() throws { + var container = OpenAPIObjectContainer() + try container.setValue(validating: ["a": 1, "b": "two"]) + XCTAssertEqual(container.value["a"] as? Int, 1) + XCTAssertEqual(container.value["b"] as? String, "two") + } + + func testSetValueValidating_objectContainer_rejectsUnsupportedValue() throws { + struct Foobar: Sendable {} + var container = try OpenAPIObjectContainer(unvalidatedValue: ["seed": "ok"]) + XCTAssertThrowsError(try container.setValue(validating: ["bad": Foobar()])) { error in + guard case .invalidValue = error as? EncodingError else { + XCTFail("Unexpected error: \(error)") + return + } + } + XCTAssertEqual( + container.value["seed"] as? String, + "ok", + "value must be unchanged after a failed setValue(validating:)" + ) + } + + func testSetValueValidating_arrayContainer_acceptsSupportedValues() throws { + var container = OpenAPIArrayContainer() + try container.setValue(validating: [1, "two", true]) + XCTAssertEqual(container.value.count, 3) + XCTAssertEqual(container.value[0] as? Int, 1) + XCTAssertEqual(container.value[1] as? String, "two") + XCTAssertEqual(container.value[2] as? Bool, true) + } + + func testSetValueValidating_arrayContainer_rejectsUnsupportedValue() throws { + struct Foobar: Sendable {} + var container = try OpenAPIArrayContainer(unvalidatedValue: ["seed"]) + XCTAssertThrowsError(try container.setValue(validating: [Foobar()])) { error in + guard case .invalidValue = error as? EncodingError else { + XCTFail("Unexpected error: \(error)") + return + } + } + XCTAssertEqual( + container.value.first as? String, + "seed", + "value must be unchanged after a failed setValue(validating:)" + ) + } + func testEncoding_container_success() throws { let values: [(any Sendable)?] = [ nil, "Hello", ["key": "value", "anotherKey": [1, "two"] as [any Sendable]] as [String: any Sendable],