|
| 1 | +# Revise Swift Testing's `Attachment`/`Encodable` interop |
| 2 | + |
| 3 | +* Proposal: [ST-NNNN](NNNN-revise-attachment-encodable-interfaces.md) |
| 4 | +* Authors: [Jonathan Grynspan](https://github.com/grynspan) |
| 5 | +* Review Manager: TBD |
| 6 | +* Status: **Awaiting review** |
| 7 | +* Implementation: [swiftlang/swift-testing#1770](https://github.com/swiftlang/swift-testing/pull/1770) |
| 8 | +* Review: ([pitch](https://forums.swift.org/...)) |
| 9 | + |
| 10 | +## Introduction |
| 11 | + |
| 12 | +We introduced **attachments** to Swift Testing in Swift 6.2 with [ST-0009][]. |
| 13 | +As part of that initial introduction, we included default implementations of our |
| 14 | +[`Attachable`][] protocol's requirements for types that already conform to |
| 15 | +[`Encodable`][] or [`NSSecureCoding`][]. |
| 16 | + |
| 17 | +This proposal revises the interfaces for attaching a value that conforms to one |
| 18 | +of those protocols to resolve some constraints that come with the current |
| 19 | +interface and implementation. |
| 20 | + |
| 21 | +## Motivation |
| 22 | + |
| 23 | +We have identified several deficiencies in the current interface: |
| 24 | + |
| 25 | +- Test authors must explicitly add conformance to [`Attachable`][] to their |
| 26 | + types that already conform to [`Encodable`][] or [`NSSecureCoding`][], which |
| 27 | + is boilerplate that doesn't really _do_ anything other than satisfy Swift's |
| 28 | + type system. |
| 29 | +- The only way to select between property list and JSON encodings is to specify |
| 30 | + a path extension in the attachment's preferred name. |
| 31 | +- There is no way to customize the encoder's settings. For example, there is no |
| 32 | + way to configure [`JSONEncoder.outputFormatting`](https://developer.apple.com/documentation/foundation/jsonencoder/outputformatting-swift.property). |
| 33 | +- Types that conform to _both_ [`Encodable`][] and [`NSSecureCoding`][] are |
| 34 | + ambiguously encoded. We document these types as using the [`Encodable`][] |
| 35 | + encoding by default, but there is no way to customize this behavior. |
| 36 | + |
| 37 | +## Proposed solution |
| 38 | + |
| 39 | +New [`Attachment`][] initializers are introduced that behave similarly to the |
| 40 | +initializers introduced for file URLs in [ST-0009][] and for [`Transferable`][]-conforming |
| 41 | +types in [ST-0023][]. |
| 42 | + |
| 43 | +The existing default implementations of [`Attachable`][]'s requirements are |
| 44 | +marked to-be-deprecated. Their documentation will automatically include |
| 45 | +migration information, but new diagnostics will not be emitted at build time |
| 46 | +until a future Swift release. |
| 47 | + |
| 48 | +## Detailed design |
| 49 | + |
| 50 | +The following extension to `Attachment` is added and is available when |
| 51 | +test authors import both `Testing` and `Foundation` in a Swift file: |
| 52 | + |
| 53 | +```swift |
| 54 | +extension Attachment { |
| 55 | + /// Initialize an instance of this type representing a value that conforms to |
| 56 | + /// the [`Encodable`][] |
| 57 | + /// protocol. |
| 58 | + /// |
| 59 | + /// - Parameters: |
| 60 | + /// - encodableValue: The value to encode and attach. |
| 61 | + /// - encodingFormat: The encoding format to use to encode `encodableValue`. |
| 62 | + /// - preferredName: The preferred name of the attachment when writing it to |
| 63 | + /// a test report or to disk. |
| 64 | + /// - sourceLocation: The source location of the call to this initializer. |
| 65 | + /// This value is used when recording issues associated with the |
| 66 | + /// attachment. |
| 67 | + /// |
| 68 | + /// - Throws: If an appropriate encoder could not be found given the |
| 69 | + /// `encodingFormat` and `preferredName` arguments. |
| 70 | + /// |
| 71 | + /// Use this initializer to create an instance of ``Attachment`` from a value |
| 72 | + /// that conforms to the [`Encodable`][] |
| 73 | + /// protocol: |
| 74 | + /// |
| 75 | + /// ```swift |
| 76 | + /// let menu = FoodTruck.currentMenu |
| 77 | + /// let attachment = try Attachment(encoding: menu, as: .json) |
| 78 | + /// Attachment.record(attachment) |
| 79 | + /// ``` |
| 80 | + /// |
| 81 | + /// The encoding that the testing library uses depends on the `encodingFormat` |
| 82 | + /// argument. If the value of that argument is `nil`, the testing library |
| 83 | + /// derives the format from the path extension you specify in `preferredName`. |
| 84 | + /// |
| 85 | + /// | Extension | Encoding Used | Encoder Used | |
| 86 | + /// |-|-|-| |
| 87 | + /// | `".xml"` | XML property list | `PropertyListEncoder` | |
| 88 | + /// | `".plist"` | Binary property list | `PropertyListEncoder` | |
| 89 | + /// | None, `".json"` | JSON | `JSONEncoder` | |
| 90 | + /// |
| 91 | + /// - Important: OpenStep-style property lists are not supported. |
| 92 | + /// |
| 93 | + /// If the values of both the `encodingFormat` and `preferredName` arguments |
| 94 | + /// are `nil`, the testing library encodes `encodableValue` as JSON. |
| 95 | + public init<T>( |
| 96 | + encoding encodableValue: T, |
| 97 | + as encodingFormat: AttachableEncodingFormat? = nil, |
| 98 | + named preferredName: String? = nil, |
| 99 | + sourceLocation: SourceLocation = #_sourceLocation |
| 100 | + ) throws where AttachableValue == _AttachableEncodableWrapper<T, Void>, T: Encodable |
| 101 | + |
| 102 | +#if canImport(Combine) |
| 103 | + /// Initialize an instance of this type representing a value that conforms to |
| 104 | + /// the [`Encodable`][] |
| 105 | + /// protocol. |
| 106 | + /// |
| 107 | + /// - Parameters: |
| 108 | + /// - encodableValue: The value to encode and attach. |
| 109 | + /// - encoder: The encoder to use to encode `encodableValue`. |
| 110 | + /// - preferredName: The preferred name of the attachment when writing it to |
| 111 | + /// a test report or to disk. |
| 112 | + /// - sourceLocation: The source location of the call to this initializer. |
| 113 | + /// This value is used when recording issues associated with the |
| 114 | + /// attachment. |
| 115 | + /// |
| 116 | + /// - Throws: If `encoder` cannot be used to encode `encodableValue`. |
| 117 | + /// |
| 118 | + /// Use this initializer to create an instance of ``Attachment`` from a value |
| 119 | + /// that conforms to the [`Encodable`][] |
| 120 | + /// protocol: |
| 121 | + /// |
| 122 | + /// ```swift |
| 123 | + /// let menu = FoodTruck.currentMenu |
| 124 | + /// let encoder = JSONEncoder() |
| 125 | + /// let attachment = try Attachment(encoding: menu, using: encoder) |
| 126 | + /// Attachment.record(attachment) |
| 127 | + /// ``` |
| 128 | + public init<T, E>( |
| 129 | + encoding encodableValue: T, |
| 130 | + using encoder: E, |
| 131 | + named preferredName: String? = nil, |
| 132 | + sourceLocation: SourceLocation = #_sourceLocation |
| 133 | + ) throws where AttachableValue == _AttachableEncodableWrapper<T, E>, T: Encodable, E: TopLevelEncoder, E.Output: ContiguousBytes |
| 134 | +#endif |
| 135 | + |
| 136 | + /// Initialize an instance of this type representing a value that conforms to |
| 137 | + /// the [`NSSecureCoding`][] |
| 138 | + /// protocol. |
| 139 | + /// |
| 140 | + /// - Parameters: |
| 141 | + /// - encodableValue: The value to encode and attach. |
| 142 | + /// - propertyListFormat: The property list format to use to encode |
| 143 | + /// `encodableValue`. |
| 144 | + /// - preferredName: The preferred name of the attachment when writing it to |
| 145 | + /// a test report or to disk. |
| 146 | + /// - sourceLocation: The source location of the call to this initializer. |
| 147 | + /// This value is used when recording issues associated with the |
| 148 | + /// attachment. |
| 149 | + /// |
| 150 | + /// - Throws: If an appropriate encoder could not be found given the |
| 151 | + /// `propertyListFormat` and `preferredName` arguments. |
| 152 | + /// |
| 153 | + /// Use this initializer to create an instance of ``Attachment`` from a value |
| 154 | + /// that conforms to the [`NSSecureCoding`][] |
| 155 | + /// protocol: |
| 156 | + /// |
| 157 | + /// ```swift |
| 158 | + /// let menu = FoodTruck.currentMenu |
| 159 | + /// let attachment = try Attachment(encoding: menu, as: .xml) |
| 160 | + /// Attachment.record(attachment) |
| 161 | + /// ``` |
| 162 | + /// |
| 163 | + /// The encoding that the testing library uses depends on the |
| 164 | + /// `propertyListFormat` argument. If the value of that argument is `nil`, the |
| 165 | + /// testing library derives the format from the path extension you specify in |
| 166 | + /// `preferredName`. |
| 167 | + /// |
| 168 | + /// | Extension | Encoding Used | Encoder Used | |
| 169 | + /// |-|-|-| |
| 170 | + /// | `".xml"` | XML property list | `NSKeyedArchiver` | |
| 171 | + /// | None, `".plist"` | Binary property list | `NSKeyedArchiver` | |
| 172 | + /// |
| 173 | + /// - Important: OpenStep-style property lists are not supported. |
| 174 | + /// |
| 175 | + /// If the values of both the `propertyListFormat` and `preferredName` |
| 176 | + /// arguments are `nil`, the testing library encodes `encodableValue` as a |
| 177 | + /// binary property list. |
| 178 | + public init<T>( |
| 179 | + encoding encodableValue: T, |
| 180 | + as propertyListFormat: PropertyListSerialization.PropertyListFormat? = nil, |
| 181 | + named preferredName: String? = nil, |
| 182 | + sourceLocation: SourceLocation = #_sourceLocation |
| 183 | + ) throws where AttachableValue == _AttachableEncodableWrapper<T, NSKeyedArchiver>, T: NSSecureCoding |
| 184 | +} |
| 185 | +``` |
| 186 | + |
| 187 | +> [!NOTE] |
| 188 | +> The `_AttachableEncodableWrapper` is a type that conforms to the |
| 189 | +> [`AttachableWrapper`][] protocol and is an implementation detail. Test authors |
| 190 | +> do not need to use this type directly. |
| 191 | +
|
| 192 | +The `AttachableEncodingFormat` type is declared as follows: |
| 193 | + |
| 194 | +```swift |
| 195 | +/// An enumeration describing the encoding formats that you can use when |
| 196 | +/// attaching a value that conforms to [`Encodable`](https://developer.apple.com/documentation/swift/encodable). |
| 197 | +/// |
| 198 | +/// Pass an instance of this type to ``Testing/Attachment/init(encoding:as:named:sourceLocation:)`` |
| 199 | +/// to specify what encoder and format to use when the testing library saves the |
| 200 | +/// resulting attachment. |
| 201 | +/// |
| 202 | +/// If you want to attach a value that conforms to [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding), |
| 203 | +/// use [`PropertyListFormat`](https://developer.apple.com/documentation/foundation/propertylistserialization/propertylistformat) |
| 204 | +/// instead. |
| 205 | +public struct AttachableEncodingFormat: Sendable, Equatable { |
| 206 | + /// Create an instance of this type representing a property list format. |
| 207 | + /// |
| 208 | + /// - Parameters: |
| 209 | + /// - format: The corresponding property list format. |
| 210 | + /// |
| 211 | + /// - Returns: An instance of this type representing `format`. |
| 212 | + public static func propertyListFormat(_ format: PropertyListSerialization.PropertyListFormat) -> Self |
| 213 | + |
| 214 | + /// An instance of this type representing the JSON format. |
| 215 | + public static var json: Self { get } |
| 216 | +} |
| 217 | +``` |
| 218 | + |
| 219 | +Test authors can use the new initializers to create attachments from any value |
| 220 | +that conforms to [`Encodable`][] or [`NSSecureCoding`][]. These values do not |
| 221 | +need a _pro forma_ conformance to [`Attachable`][]: |
| 222 | + |
| 223 | +```swift |
| 224 | +@Test func `Bake an apple pie`() throws { |
| 225 | + let oven = Oven() |
| 226 | + oven.warm(to: .fahrenheit(400)) |
| 227 | + ... |
| 228 | + let attachment = try Attachment(encoding: recipe, as: .propertyListFormat(.binary)) |
| 229 | + Attachment.record(attachment) |
| 230 | + ... |
| 231 | + #expect(pie.isDelicious) |
| 232 | +} |
| 233 | + |
| 234 | +// OR: |
| 235 | + |
| 236 | +@Test func `Bake an apple pie`() throws { |
| 237 | + let oven = Oven() |
| 238 | + oven.warm(to: .fahrenheit(400)) |
| 239 | + ... |
| 240 | + let encoder = JSONEncoder() |
| 241 | + encoder.outputFormatting = [.prettyPrinted, ...] |
| 242 | + let attachment = try Attachment(encoding: recipe, using: encoder) |
| 243 | + Attachment.record(attachment) |
| 244 | + ... |
| 245 | + #expect(pie.isScrumptious) |
| 246 | +} |
| 247 | +``` |
| 248 | + |
| 249 | +If the test author does not specify an encoding format or encoder, Swift Testing |
| 250 | +makes a best effort to derive the encoding format from the attachment's |
| 251 | +preferred name (as with the existing interface). If the test author doesn't |
| 252 | +specify a preferred name either, Swift Testing defaults to JSON (for [`Encodable`][] |
| 253 | +types) or the binary property list format (for [`NSSecureCoding`][] types). |
| 254 | + |
| 255 | +### Deprecation of existing interfaces |
| 256 | + |
| 257 | +The existing default implementations are marked to-be-deprecated: |
| 258 | + |
| 259 | +```swift |
| 260 | +extension Attachable where Self: Encodable { |
| 261 | + /// @DeprecationSummary { |
| 262 | + /// Use ``Attachment/init(encoding:as:named:sourceLocation:)`` instead: |
| 263 | + /// |
| 264 | + /// let attachment = try Attachment(encoding: someValue, as: .json) |
| 265 | + /// Attachment.record(attachment) |
| 266 | + /// } |
| 267 | + @available(swift, introduced: 6.2, deprecated: 100000.0, message: "Use 'Attachment.init(encoding:as:named:sourceLocation:)' instead") |
| 268 | + public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R |
| 269 | +} |
| 270 | + |
| 271 | +extension Attachable where Self: NSSecureCoding { |
| 272 | + /// @DeprecationSummary { |
| 273 | + /// Use ``Attachment/init(encoding:as:named:sourceLocation:)`` instead: |
| 274 | + /// |
| 275 | + /// let attachment = try Attachment(encoding: someValue, as: .binary) |
| 276 | + /// Attachment.record(attachment) |
| 277 | + /// } |
| 278 | + @available(swift, introduced: 6.2, deprecated: 100000.0, message: "Use 'Attachment.init(encoding:as:named:sourceLocation:)' instead") |
| 279 | + public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R |
| 280 | +} |
| 281 | +``` |
| 282 | + |
| 283 | +### `TopLevelEncoder` on non-Apple platforms |
| 284 | + |
| 285 | +[`TopLevelEncoder`][] is declared in the Combine framework which is part of |
| 286 | +Apple's SDKs but not part of the open source Swift project. As such, it is not |
| 287 | +available on non-Apple platforms. See **future directions** for more details; in |
| 288 | +the mean time, concrete overloads of `init(encoding:using:named:sourceLocation:)` |
| 289 | +are added on non-Apple platforms to cover the common use cases: |
| 290 | + |
| 291 | +```swift |
| 292 | +#if !canImport(Combine) |
| 293 | +extension Attachment { |
| 294 | + public init<T, E>( |
| 295 | + encoding encodableValue: T, |
| 296 | + using encoder: E, |
| 297 | + named preferredName: String? = nil, |
| 298 | + sourceLocation: SourceLocation = #_sourceLocation |
| 299 | + ) throws where AttachableValue == _AttachableEncodableWrapper<T, E>, T: Encodable, E: PropertyListEncoder |
| 300 | + |
| 301 | + public init<T, E>( |
| 302 | + encoding encodableValue: T, |
| 303 | + using encoder: E, |
| 304 | + named preferredName: String? = nil, |
| 305 | + sourceLocation: SourceLocation = #_sourceLocation |
| 306 | + ) throws where AttachableValue == _AttachableEncodableWrapper<T, E>, T: Encodable, E: JSONEncoder |
| 307 | +} |
| 308 | +#endif |
| 309 | +``` |
| 310 | + |
| 311 | +## Source compatibility |
| 312 | + |
| 313 | +These changes are additive and should not impact existing code. |
| 314 | + |
| 315 | +## Integration with supporting tools |
| 316 | + |
| 317 | +No changes. Supporting tools that consume attachments should "just work". |
| 318 | + |
| 319 | +## Future directions |
| 320 | + |
| 321 | +- **Lowering [`TopLevelEncoder`][] to the standard library.** If we do this, then |
| 322 | + the protocol becomes available on non-Apple platforms and our hard-coded |
| 323 | + workarounds can be removed. (The author intends to propose a change here, but |
| 324 | + it is beyond the scope of _this_ proposal). |
| 325 | + |
| 326 | +- **Formally deprecating the existing interface.** As discussed above, a future |
| 327 | + Swift toolchain release will emit deprecation warnings at build time when a |
| 328 | + type conforms to both [`Encodable`][]/[`NSSecureCoding`][] and [`Attachable`][] |
| 329 | + and relies on the default implementations of [`Attachable`][]'s requirements. |
| 330 | + |
| 331 | +## Alternatives considered |
| 332 | + |
| 333 | +- **Exposing new overloads of `Attachment.record()` instead of `Attachment.init()`.** |
| 334 | + See [ST-0023][] for more information why we prefer to overload `init()` here |
| 335 | + instead of `record()`. |
| 336 | + |
| 337 | +- **Dropping support for [`NSSecureCoding`][].** While [`Encodable`][] is the |
| 338 | + preferred serialization protocol in Swift, [`NSSecureCoding`][] remains |
| 339 | + supported at the Foundation layer. |
| 340 | + |
| 341 | +- **Leaving the existing interfaces undeprecated while adding the new ones.** |
| 342 | + The existing interfaces have the aforementioned ergonomic deficiencies. We |
| 343 | + don't want test authors to add conformances to [`Attachable`][] by rote, and |
| 344 | + we don't want to support two entirely distinct mechanisms for attaching |
| 345 | + encodable values when only one is needed (let alone where only one is |
| 346 | + _recommended_). |
| 347 | + |
| 348 | +[`Attachable`]: https://developer.apple.com/documentation/testing/attachable |
| 349 | +[`AttachableWrapper`]: https://developer.apple.com/documentation/testing/attachablewrapper |
| 350 | +[`Attachment`]: https://developer.apple.com/documentation/testing/attachment |
| 351 | +[`Encodable`]: https://developer.apple.com/documentation/swift/encodable |
| 352 | +[`NSSecureCoding`]: https://developer.apple.com/documentation/foundation/nssecurecoding |
| 353 | +[`TopLevelEncoder`]: https://developer.apple.com/documentation/combine/toplevelencoder |
| 354 | +[`Transferable`]: https://developer.apple.com/documentation/coretransferable/transferable |
| 355 | + |
| 356 | +[ST-0009]: https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0009-attachments.md |
| 357 | +[ST-0023]: https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0023-attachments-transferable.md |
0 commit comments