Skip to content

Commit

Permalink
Add an associated AttachmentMetadata type to Attachable.
Browse files Browse the repository at this point in the history
This PR adds a new associated type to `Attachable` that can be used to supply
additional metadata to an attachment that is specific to that type. Metadata is
always optional and the default type is `Never` (i.e. by default there is no
metadata.)

The `Encodable` and `NSSecureCoding` conformances in the Foundation cross-import
overlay have been updated such that this type equals a new structure that
describes the format to use as well as options to pass to the JSON encoder (if
one is used) and user info to pass to the plist or JSON encoders (if used.) We
must use this type even for types that conform only to `NSSecureCoding`,
otherwise we get compile-time errors about the type being ambiguous if a type
conforms to both protocols and to `Attachable`.
  • Loading branch information
grynspan committed Dec 11, 2024
1 parent 4be1dfe commit 4a39dfa
Show file tree
Hide file tree
Showing 8 changed files with 314 additions and 88 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public import Foundation

@_spi(Experimental)
extension Attachable where Self: Encodable & NSSecureCoding {
public typealias AttachmentMetadata = EncodableAttachmentMetadata

@_documentation(visibility: private)
public func withUnsafeBufferPointer<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
try _Testing_Foundation.withUnsafeBufferPointer(encoding: self, for: attachment, body)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@ private import Foundation
/// - Throws: Whatever is thrown by `body`, or any error that prevented the
/// creation of the buffer.
func withUnsafeBufferPointer<E, R>(encoding attachableValue: borrowing E, for attachment: borrowing Attachment<E>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R where E: Attachable & Encodable {
let format = try EncodingFormat(for: attachment)
let format = try EncodableAttachmentMetadata.Format(for: attachment)

let data: Data
switch format {
case let .propertyListFormat(propertyListFormat):
let plistEncoder = PropertyListEncoder()
plistEncoder.outputFormat = propertyListFormat
if let metadata = attachment.metadata as? EncodableAttachmentMetadata {
plistEncoder.userInfo = metadata.userInfo
}
data = try plistEncoder.encode(attachableValue)
case .default:
// The default format is JSON.
Expand All @@ -44,7 +47,19 @@ func withUnsafeBufferPointer<E, R>(encoding attachableValue: borrowing E, for at
// require it be exported with (at least) package visibility which would
// create a visible external dependency on Foundation in the main testing
// library target.
data = try JSONEncoder().encode(attachableValue)
let jsonEncoder = JSONEncoder()
if let metadata = attachment.metadata as? EncodableAttachmentMetadata {
jsonEncoder.userInfo = metadata.userInfo

if let options = metadata.jsonEncodingOptions {
jsonEncoder.outputFormatting = options.outputFormatting
jsonEncoder.dateEncodingStrategy = options.dateEncodingStrategy
jsonEncoder.dataEncodingStrategy = options.dataEncodingStrategy
jsonEncoder.nonConformingFloatEncodingStrategy = options.nonConformingFloatEncodingStrategy
jsonEncoder.keyEncodingStrategy = options.keyEncodingStrategy
}
}
data = try jsonEncoder.encode(attachableValue)
}

return try data.withUnsafeBytes(body)
Expand All @@ -55,6 +70,8 @@ func withUnsafeBufferPointer<E, R>(encoding attachableValue: borrowing E, for at
// protocol for types that already support Codable.
@_spi(Experimental)
extension Attachable where Self: Encodable {
public typealias AttachmentMetadata = EncodableAttachmentMetadata

/// Encode this value into a buffer using either [`PropertyListEncoder`](https://developer.apple.com/documentation/foundation/propertylistencoder)
/// or [`JSONEncoder`](https://developer.apple.com/documentation/foundation/jsonencoder),
/// then call a function and pass that buffer to it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public import Foundation
// NSKeyedArchiver for encoding.
@_spi(Experimental)
extension Attachable where Self: NSSecureCoding {
public typealias AttachmentMetadata = EncodableAttachmentMetadata

/// Encode this object using [`NSKeyedArchiver`](https://developer.apple.com/documentation/foundation/nskeyedarchiver)
/// into a buffer, then call a function and pass that buffer to it.
///
Expand Down Expand Up @@ -51,7 +53,7 @@ extension Attachable where Self: NSSecureCoding {
/// some other path extension, that path extension must represent a type
/// that conforms to [`UTType.propertyList`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/propertylist).
public func withUnsafeBufferPointer<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R {
let format = try EncodingFormat(for: attachment)
let format = try EncodableAttachmentMetadata.Format(for: attachment)

var data = try NSKeyedArchiver.archivedData(withRootObject: self, requiringSecureCoding: true)
switch format {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

#if canImport(Foundation)
@_spi(Experimental) import Testing
public import Foundation

#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers)
private import UniformTypeIdentifiers
#endif

/// An enumeration describing the encoding formats supported by default when
/// encoding a value that conforms to ``Testing/Attachable`` and either
/// [`Encodable`](https://developer.apple.com/documentation/swift/encodable)
/// or [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding).
@_spi(Experimental)
public struct EncodableAttachmentMetadata: Sendable {
/// An enumeration describing the encoding formats supported by default when
/// encoding a value that conforms to ``Testing/Attachable`` and either
/// [`Encodable`](https://developer.apple.com/documentation/swift/encodable)
/// or [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding).
@_spi(Experimental)
public enum Format: Sendable {
/// The encoding format to use by default.
///
/// The specific format this case corresponds to depends on if we are encoding
/// an `Encodable` value or an `NSSecureCoding` value.
case `default`

/// A property list format.
///
/// - Parameters:
/// - format: The corresponding property list format.
///
/// OpenStep-style property lists are not supported.
case propertyListFormat(_ format: PropertyListSerialization.PropertyListFormat)

/// The JSON format.
case json
}

/// The format the attachable value should be encoded as.
public var format: Format

/// A type describing the various JSON encoding options to use if
/// [`JSONEncoder`](https://developer.apple.com/documentation/foundation/jsonencoder)
/// is used to encode the attachable value.
public struct JSONEncodingOptions: Sendable {
/// The output format to produce.
public var outputFormatting: JSONEncoder.OutputFormatting

/// The strategy to use in encoding dates.
public var dateEncodingStrategy: JSONEncoder.DateEncodingStrategy

/// The strategy to use in encoding binary data.
public var dataEncodingStrategy: JSONEncoder.DataEncodingStrategy

/// The strategy to use in encoding non-conforming numbers.
public var nonConformingFloatEncodingStrategy: JSONEncoder.NonConformingFloatEncodingStrategy

/// The strategy to use for encoding keys.
public var keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy
}

/// JSON encoding options to use if [`JSONEncoder`](https://developer.apple.com/documentation/foundation/jsonencoder)
/// is used to encode the attachable value.
///
/// The default value of this property is `nil`, meaning that the default
/// options are used when encoding an attachable value as JSON. If an
/// attachable value is encoded in a format other than JSON, the value of this
/// property is ignored.
public var jsonEncodingOptions: JSONEncodingOptions?

/// A user info dictionary to provide to the property list encoder or JSON
/// encoder when encoding the attachable value.
///
/// The value of this property is ignored when encoding an attachable value
/// that conforms to [`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding)
/// but does not conform to [`Encodable`](https://developer.apple.com/documentation/swift/encodable).
public var userInfo: [CodingUserInfoKey: any Sendable]

public init(format: Format, jsonEncodingOptions: JSONEncodingOptions? = nil, userInfo: [CodingUserInfoKey: any Sendable] = [:]) {
self.format = format
self.jsonEncodingOptions = jsonEncodingOptions
self.userInfo = userInfo
}
}

// MARK: -

@_spi(Experimental)
extension EncodableAttachmentMetadata.JSONEncodingOptions {
public init(
outputFormatting: JSONEncoder.OutputFormatting? = nil,
dateEncodingStrategy: JSONEncoder.DateEncodingStrategy? = nil,
dataEncodingStrategy: JSONEncoder.DataEncodingStrategy? = nil,
nonConformingFloatEncodingStrategy: JSONEncoder.NonConformingFloatEncodingStrategy? = nil,
keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy? = nil
) {
self = .default
self.outputFormatting = outputFormatting ?? self.outputFormatting
self.dateEncodingStrategy = dateEncodingStrategy ?? self.dateEncodingStrategy
self.dataEncodingStrategy = dataEncodingStrategy ?? self.dataEncodingStrategy
self.nonConformingFloatEncodingStrategy = nonConformingFloatEncodingStrategy ?? self.nonConformingFloatEncodingStrategy
self.keyEncodingStrategy = keyEncodingStrategy ?? self.keyEncodingStrategy
}

/// An instance of this type representing the default JSON encoding options
/// used by [`JSONEncoder`](https://developer.apple.com/documentation/foundation/jsonencoder).
public static let `default`: Self = {
// Get the default values from a real JSONEncoder for max authenticity!
let encoder = JSONEncoder()

return Self(
outputFormatting: encoder.outputFormatting,
dateEncodingStrategy: encoder.dateEncodingStrategy,
dataEncodingStrategy: encoder.dataEncodingStrategy,
nonConformingFloatEncodingStrategy: encoder.nonConformingFloatEncodingStrategy,
keyEncodingStrategy: encoder.keyEncodingStrategy
)
}()
}

// MARK: -

extension EncodableAttachmentMetadata.Format {
/// Initialize an instance of this type representing the content type or media
/// type of the specified attachment.
///
/// - Parameters:
/// - attachment: The attachment that will be encoded.
///
/// - Throws: If the attachment's content type or media type is unsupported.
init(for attachment: borrowing Attachment<some Attachable>) throws {
if let metadata = attachment.metadata {
if let format = metadata as? Self {
self = format
return
} else if let metadata = metadata as? EncodableAttachmentMetadata {
self = metadata.format
return
} else if let propertyListFormat = metadata as? PropertyListSerialization.PropertyListFormat {
self = .propertyListFormat(propertyListFormat)
return
}
}

let ext = (attachment.preferredName as NSString).pathExtension

#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers)
// If the caller explicitly wants to encode their data as either XML or as a
// property list, use PropertyListEncoder. Otherwise, we'll fall back to
// JSONEncoder below.
if #available(_uttypesAPI, *), let contentType = UTType(filenameExtension: ext) {
if contentType == .data {
self = .default
} else if contentType.conforms(to: .json) {
self = .json
} else if contentType.conforms(to: .xml) {
self = .propertyListFormat(.xml)
} else if contentType.conforms(to: .binaryPropertyList) || contentType == .propertyList {
self = .propertyListFormat(.binary)
} else if contentType.conforms(to: .propertyList) {
self = .propertyListFormat(.openStep)
} else {
let contentTypeDescription = contentType.localizedDescription ?? contentType.identifier
throw CocoaError(.propertyListWriteInvalid, userInfo: [NSLocalizedDescriptionKey: "The content type '\(contentTypeDescription)' cannot be used to attach an instance of \(type(of: self)) to a test."])
}
return
}
#endif

if ext.isEmpty {
// No path extension? No problem! Default data.
self = .default
} else if ext.caseInsensitiveCompare("plist") == .orderedSame {
self = .propertyListFormat(.binary)
} else if ext.caseInsensitiveCompare("xml") == .orderedSame {
self = .propertyListFormat(.xml)
} else if ext.caseInsensitiveCompare("json") == .orderedSame {
self = .json
} else {
throw CocoaError(.propertyListWriteInvalid, userInfo: [NSLocalizedDescriptionKey: "The path extension '.\(ext)' cannot be used to attach an instance of \(type(of: self)) to a test."])
}
}
}
#endif

This file was deleted.

18 changes: 18 additions & 0 deletions Sources/Testing/Attachments/Attachable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@
/// that conforms to ``AttachableContainer`` to act as a proxy.
@_spi(Experimental)
public protocol Attachable: ~Copyable {
/// A type containing additional metadata about an instance of this attachable
/// type that a developer can optionally include when creating an attachment.
///
/// Instances of this type can contain metadata that is not contained directly
/// in the attachable value itself. An instance of this type can be passed to
/// the initializers of ``Attachment`` and then accessed later via
/// ``Attachment/metadata``. Metadata is always optional; if your attachable
/// value _must_ include some value, consider adding it as a property of that
/// type instead of adding it as metadata.
///
/// When implementing ``withUnsafeBufferPointer(for:_:)``, you can access the
/// attachment's ``Attachment/metadata`` property to get the metadata that was
/// passed when the attachment was created.
///
/// By default, this type is equal to [`Never`](https://developer.apple.com/documentation/swift/never),
/// meaning that an attachable value has no metadata associated with it.
associatedtype AttachmentMetadata: Sendable & Copyable = Never

/// An estimate of the number of bytes of memory needed to store this value as
/// an attachment.
///
Expand Down
Loading

0 comments on commit 4a39dfa

Please sign in to comment.