diff --git a/Package.swift b/Package.swift index 94dcb74..dc72190 100644 --- a/Package.swift +++ b/Package.swift @@ -32,10 +32,10 @@ let package = Package( .library(name: "SpeziAccount", targets: ["SpeziAccount"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "2.0.0-beta.1"), - .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.1"), + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "2.0.0-beta.2"), + .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.3"), .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.6.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziStorage", from: "1.1.2"), + .package(url: "https://github.com/StanfordSpezi/SpeziStorage", from: "1.2.0"), .package(url: "https://github.com/StanfordBDHG/XCTRuntimeAssertions", from: "1.1.1"), .package(url: "https://github.com/apple/swift-collections", from: "1.1.2"), .package(url: "https://github.com/apple/swift-atomics", from: "1.2.0"), diff --git a/Sources/SpeziAccount/AccountDetailsCache.swift b/Sources/SpeziAccount/AccountDetailsCache.swift index 66f7900..1bd7eec 100644 --- a/Sources/SpeziAccount/AccountDetailsCache.swift +++ b/Sources/SpeziAccount/AccountDetailsCache.swift @@ -53,11 +53,13 @@ public actor AccountDetailsCache: Module, DefaultInitializable { } let decoder = JSONDecoder() - decoder.userInfo[.accountDetailsKeys] = keys + + let configuration = AccountDetails.DecodingConfiguration(keys: keys) do { let details = try localStorage.read( AccountDetails.self, + configuration: configuration, decoder: decoder, storageKey: Self.key(for: accountId), settings: storageSettings diff --git a/Sources/SpeziAccount/AccountValue/Collections/AccountDetails+Codable.swift b/Sources/SpeziAccount/AccountValue/Collections/AccountDetails+Codable.swift index 5f5f561..95e456f 100644 --- a/Sources/SpeziAccount/AccountValue/Collections/AccountDetails+Codable.swift +++ b/Sources/SpeziAccount/AccountValue/Collections/AccountDetails+Codable.swift @@ -6,6 +6,8 @@ // SPDX-License-Identifier: MIT // +import Foundation + extension AccountDetails { /// Use an `AccountKey` as a `CodingKey`. @@ -43,49 +45,134 @@ extension AccountDetails { } -extension AccountDetails: Codable { - /// Decodes the contents of a account details collection. +extension AccountDetails { + /// The configuration that is required to decode account details. /// - /// - Warning: Decoding an `AccountDetails` requires knowledge of the ``AccountKey``s to decode. Therefore, - /// you must supply the keys using the the ``Swift/CodingUserInfoKey/accountDetailsKeys`` userInfo key. + /// ```swift + /// let keys: [any AccountKey.Type] = [AccountKeys.name, AccountKeys.dateOfBirth] /// - /// - Note: You can opt into lazy decoding using the ``Swift/CodingUserInfoKey/lazyAccountDetailsDecoding`` userInfo key. + /// let decoder = JSONDecoder() + /// let configuration = AccountDetails.DecodingConfiguration(keys: keys) + /// try decoder.decode(AccountDetails.self, from: data, configuration: configuration) + /// ``` + public struct DecodingConfiguration { + /// The list of keys to decode. + /// + /// The decode implementation of `AccountDetails` needs prior knowledge of what keys to expect and which type they are. + /// Therefore, you need a list of all ``AccountKey``s to expect. + public let keys: [any AccountKey.Type] + /// Customize the identifier mapping. + /// + /// Instead of using the ``AccountKey/identifier`` defined by the account key, you can provide a custom mapping when encoding and decoding. + /// Identifiers that are not specified but requested to be decoded or encoded will fallback to the identifier provided by the account key. + /// + /// - Important: You must specify the `identifierMapping` for both the encoder and decoder. + public let identifierMapping: [String: any AccountKey.Type]? // swiftlint:disable:this discouraged_optional_collection + /// Decode `AccountDetails` with a best effort approach. + /// + /// By default, decoding `AccountDetails` throws an error if any of the values fail to decode. In certain situations, it might be useful to allow certain values to fail decoding + /// and nonetheless use the details that succeeded decoding. + /// You can opt into this behavior using this option. + /// + /// - Note: You can access all decoding errors using the ``AccountDetails/decodingErrors`` property. Make sure to reset this property to nil. + /// + /// ```swift + /// let keys: [any AccountKey.Type] = [AccountKeys.name, AccountKeys.dateOfBirth] + /// let decoder = JSONDecoder() + /// let configuration = AccountDetails.DecodingConfiguration(keys: keys, lazyDecoding: true) + /// + /// let decoded = try decoder.decode(AccountDetails.self, from: data, configuration: configuration) + /// if let errors = decoded.decodingErrors { + /// // handle errors ... + /// decoded.decodingErrors = nil + /// } + /// ``` + public let lazyDecoding: Bool + /// Require that all `accountDetailsKeys` are present. + /// + /// If this option is set to `true`, decoding will fail if a key present in ``keys`` is not found while decoding. + /// A value of `false` (the default) will decode only the keys found. + public let requireAllKeys: Bool + + /// Create a new decoding configuration. + /// - Parameters: + /// - keys: The list of keys to decode. + /// - identifierMapping: Customize the identifier mapping. + /// - lazyDecoding: Decode `AccountDetails` with a best effort approach. + /// - requireAllKeys: Require that all `accountDetailsKeys` are present. + public init( + keys: [any AccountKey.Type], + identifierMapping: [String: any AccountKey.Type]? = nil, // swiftlint:disable:this discouraged_optional_collection + lazyDecoding: Bool = false, + requireAllKeys: Bool = false + ) { + self.keys = keys + self.identifierMapping = identifierMapping + self.lazyDecoding = lazyDecoding + self.requireAllKeys = requireAllKeys + } + } + + /// Supply additional configuration when encoding. /// - /// - Note: You can customize the identifier mapping using ``Swift/CodingUserInfoKey/accountKeyIdentifierMapping``. + /// ```swift + /// let details = AccountDetails() + /// let mapping: [String: any AccountKey.Type] = [ + /// "DateOfBirthKey": AccountKeys.dateOfBirth + /// ] /// - /// - Parameter decoder: The decoder. - public init(from decoder: any Decoder) throws { - guard let anyKeys = decoder.userInfo[.accountDetailsKeys] else { - throw DecodingError.dataCorrupted(.init( - codingPath: decoder.codingPath, - debugDescription: """ - AccountKeys unspecified. Do decode AccountDetails you must specify requested AccountKey types \ - via the `accountDetailsKeys` CodingUserInfoKey. - """ - )) - } - - guard let keys = anyKeys as? [any AccountKey.Type] else { - throw DecodingError.dataCorrupted(.init( - codingPath: decoder.codingPath, - debugDescription: """ - Supplied `accountDetailsKeys` of type \(type(of: anyKeys)) did not match expected type \ - of \([any AccountKey.Type].self). - """ - )) + /// let encoder = JSONEncoder() + /// let configuration = AccountDetails.EncodingConfiguration(identifierMapping: mapping) + /// try encoder.encode(details, configuration: configuration) + /// ``` + public struct EncodingConfiguration { + /// Customize the identifier mapping. + /// + /// Instead of using the ``AccountKey/identifier`` defined by the account key, you can provide a custom mapping when encoding and decoding. + /// Identifiers that are not specified but requested to be decoded or encoded will fallback to the identifier provided by the account key. + /// + /// - Important: You must specify the `identifierMapping` for both the encoder and decoder. + public let identifierMapping: [String: any AccountKey.Type]? // swiftlint:disable:this discouraged_optional_collection + + /// Create a new encoding configuration. + /// - Parameters: + /// - identifierMapping: Customize the identifier mapping. + public init( + identifierMapping: [String: any AccountKey.Type]? = nil // swiftlint:disable:this discouraged_optional_collection + ) { + self.identifierMapping = identifierMapping } + } +} + + +extension AccountDetails.DecodingConfiguration: Sendable {} - let requireKeys = decoder.userInfo[.requireAllKeys] as? Bool == true - let mapping = decoder.userInfo[.accountKeyIdentifierMapping] as? [String: any AccountKey.Type] +extension AccountDetails.EncodingConfiguration: Sendable {} + + +extension AccountDetails: CodableWithConfiguration, Encodable { + /// Decodes the contents of a account details collection. + /// + /// Use the ``DecodingConfiguration`` to supply mandatory options, like providing the list of ``AccountKey``s to decode. + /// + /// - Note: You can opt into lazy decoding using the ``DecodingConfiguration/lazyDecoding`` option or customize the identifier mapping + /// using ``DecodingConfiguration/identifierMapping``. + /// + /// - Parameters: + /// - decoder: The decoder. + /// - configuration: The decoding configuration. + public init(from decoder: any Decoder, configuration: DecodingConfiguration) throws { let container = try decoder.container(keyedBy: AccountKeyCodingKey.self) - var visitor = DecoderVisitor(container, required: requireKeys, mapping: mapping.map { .init(mapping: $0) }) - let details = keys.acceptAll(&visitor) + let mapping = configuration.identifierMapping.map { IdentifierMapping(mapping: $0) } + + var visitor = DecoderVisitor(container, required: configuration.requireAllKeys, mapping: mapping) + let details = configuration.keys.acceptAll(&visitor) if let error = visitor.errors.first { - if let lazyDecoding = decoder.userInfo[.lazyAccountDetailsDecoding] as? Bool, - lazyDecoding { + if configuration.lazyDecoding { self = details self.decodingErrors = visitor.errors } else { @@ -100,15 +187,27 @@ extension AccountDetails: Codable { /// /// This implementation iterates over all ``AccountKey``s and encodes them with their respective `Codable` implementation. /// - /// - Note: You can customize the identifier mapping using ``Swift/CodingUserInfoKey/accountKeyIdentifierMapping``. + /// - Note: You can customize the identifier mapping using the ``EncodingConfiguration``. /// /// - Parameter encoder: The encoder. public func encode(to encoder: any Encoder) throws { - let mapping = encoder.userInfo[.accountKeyIdentifierMapping] as? [String: any AccountKey.Type] + try encode(to: encoder, configuration: EncodingConfiguration()) + } + /// Encode the contents of the collection. + /// + /// This implementation iterates over all ``AccountKey``s and encodes them with their respective `Codable` implementation. + /// Further, it uses the custom identifier mapping from ``EncodingConfiguration/identifierMapping``. + /// + /// - Parameters: + /// - encoder: The encoder. + /// - configuration: The encoding configuration. + public func encode(to encoder: any Encoder, configuration: EncodingConfiguration) throws { let container = encoder.container(keyedBy: AccountKeyCodingKey.self) - var visitor = EncoderVisitor(container, mapping: mapping.map { .init(mapping: $0) }) + let mapping = configuration.identifierMapping.map { IdentifierMapping(mapping: $0) } + + var visitor = EncoderVisitor(container, mapping: mapping) let result = acceptAll(&visitor) if case let .failure(error) = result { @@ -202,87 +301,3 @@ extension AccountDetails { } } } - - -extension CodingUserInfoKey { - /// Provide the keys to decode to a decoder for `AccountDetails`. - /// - /// The decode implementation of `AccountDetails` needs prior knowledge of what keys to expect and which type they are. - /// Therefore, you need a list of all ``AccountKey``s to expect. You can use this userInfo key to supply this list. - /// - /// ```swift - /// let keys: [any AccountKey.Type] = [AccountKeys.name, AccountKeys.dateOfBirth] - /// let decoder = JSONDecoder() - /// decoder.userInfo[.accountDetailsKeys] = keys - /// ``` - public static let accountDetailsKeys: CodingUserInfoKey = { - guard let key = CodingUserInfoKey(rawValue: "edu.stanford.spezi.account.details-keys") else { - preconditionFailure("Unable to create `accountDetailsKeys` CodingUserInfoKey!") - } - return key - }() - - /// Customize the identifier mapping. - /// - /// Instead of using the ``AccountKey/identifier`` defined by the account key, you can provide a custom mapping using this user info when encoding and decoding. - /// Identifiers that are not specified but requested to be decoded or encoded will fallback to the identifier provided by the account key. - /// - /// - Important: You must specify the `accountKeyIdentifierMapping` userInfo for both the encoder and decoder. - /// - /// ```swift - /// let keys: [any AccountKey.Type] = [AccountKeys.name, AccountKeys.dateOfBirth] - /// let decoder = JSONDecoder() - /// decoder.userInfo[.accountDetailsKeys] = keys - /// decoder.userInfo[.accountKeyIdentifierMapping] = ["PersonNameKey": AccountKeys.name, "DateOfBirthKey": AccountKeys.dateOfBirth] - /// ``` - public static let accountKeyIdentifierMapping: CodingUserInfoKey = { - guard let key = CodingUserInfoKey(rawValue: "edu.stanford.spezi.account.identifier-override") else { - preconditionFailure("Unable to create `accountKeyIdentifierMapping` CodingUserInfoKey!") - } - return key - }() - - - /// Decode `AccountDetails` with a best effort approach. - /// - /// By default, decoding `AccountDetails` throws an error if any of the values fail to decode. In certain situations, it might be useful to allow certain values to fail decoding - /// and nonetheless use the details that succeeded decoding. - /// You can opt into this behavior using this userInfo key. - /// - /// - Note: You can access all decoding errors using the ``AccountDetails/decodingErrors`` property. Make sure to reset this property to nil. - /// - /// ```swift - /// let decoder = JSONDecoder() - /// decoder.userInfo[.lazyAccountDetailsDecoding] = true - /// - /// var decoded = decoder.decode(AccountDetails.self, from: data) - /// if let errors = decoded.decodingErrors { - /// // handle errors - /// decoded.decodingErrors = nil - /// } - /// ``` - public static let lazyAccountDetailsDecoding: CodingUserInfoKey = { - guard let key = CodingUserInfoKey(rawValue: "edu.stanford.spezi.account.collect-errors-oob") else { - preconditionFailure("Unable to create `collectCodingErrorsOutOfBand` CodingUserInfoKey!") - } - return key - }() - - /// Require that all `accountDetailsKeys` are present. - /// - /// If this key is set to `true`, decoding will fail with a key present present in ``Swift/CodingUserInfoKey/accountDetailsKeys`` is not found while decoding. - /// A value of `false` (the default) will decode only the keys found. - /// - /// ```swift - /// let keys: [any AccountKey.Type] = [AccountKeys.name, AccountKeys.dateOfBirth] - /// let decoder = JSONDecoder() - /// decoder.userInfo[.accountDetailsKeys] = keys - /// decoder.userInfo[.requireAllKeys] = true - /// ``` - public static let requireAllKeys: CodingUserInfoKey = { - guard let key = CodingUserInfoKey(rawValue: "edu.stanford.spezi.account.require-all-keys") else { - preconditionFailure("Unable to create `requireAllKeys` CodingUserInfoKey!") - } - return key - }() -} diff --git a/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift b/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift index 0d5eebe..c004718 100644 --- a/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift +++ b/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift @@ -129,7 +129,8 @@ private struct CopyKeyVisitor: AccountKeyVisitor { /// - ``add(contentsOf:merge:)`` /// - ``add(contentsOf:filterFor:merge:)-358td`` /// - ``add(contentsOf:filterFor:merge:)-7dmho`` -/// - ``set(_:value:)`` +/// - ``set(_:value:)-24wpe`` +/// - ``set(_:value:)-8yml1`` /// /// ### Removing Details /// @@ -164,14 +165,13 @@ private struct CopyKeyVisitor: AccountKeyVisitor { /// /// ### Codable /// -/// - ``init(from:)`` +/// - ``DecodingConfiguration`` +/// - ``init(from:configuration:)`` +/// - ``EncodingConfiguration`` /// - ``encode(to:)`` -/// - ``decodingErrors`` -/// - ``Swift/CodingUserInfoKey/accountDetailsKeys`` -/// - ``Swift/CodingUserInfoKey/accountKeyIdentifierMapping`` -/// - ``Swift/CodingUserInfoKey/lazyAccountDetailsDecoding`` -/// - ``Swift/CodingUserInfoKey/requireAllKeys`` +/// - ``encode(to:configuration:)`` /// - ``AccountKeyCodingKey`` +/// - ``decodingErrors`` public struct AccountDetails { fileprivate var storage: AccountStorage @@ -264,7 +264,7 @@ extension AccountDetails: SendableSharedRepository { public func get>(_ source: Source.Type) -> Source.Value? where Source.Value: Sendable { storage.get(source) } - + public mutating func set>(_ source: Source.Type, value newValue: Source.Value?) where Source.Value: Sendable { storage.set(source, value: newValue) } diff --git a/Sources/SpeziAccount/AccountValue/Keys/AccountDetailsFlags.swift b/Sources/SpeziAccount/AccountValue/Keys/AccountDetailsFlags.swift index 94bf78e..a28d46c 100644 --- a/Sources/SpeziAccount/AccountValue/Keys/AccountDetailsFlags.swift +++ b/Sources/SpeziAccount/AccountValue/Keys/AccountDetailsFlags.swift @@ -1,13 +1,14 @@ // // This source file is part of the Stanford Spezi open-source project // -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // import SpeziFoundation + private struct AccountDetailsFlags: OptionSet, Sendable { let rawValue: UInt16 diff --git a/Tests/SpeziAccountTests/AccountDetailsTests.swift b/Tests/SpeziAccountTests/AccountDetailsTests.swift index b3404a3..6fbe46c 100644 --- a/Tests/SpeziAccountTests/AccountDetailsTests.swift +++ b/Tests/SpeziAccountTests/AccountDetailsTests.swift @@ -16,10 +16,11 @@ final class AccountDetailsTests: XCTestCase { let encoder = JSONEncoder() let decoder = JSONDecoder() - decoder.userInfo[.accountDetailsKeys] = details.keys + + let configuration = AccountDetails.DecodingConfiguration(keys: details.keys) let data = try encoder.encode(details) - let decoded = try decoder.decode(AccountDetails.self, from: data) + let decoded = try decoder.decode(AccountDetails.self, from: data, configuration: configuration) XCTAssertDetails(decoded, details) XCTAssertFalse(decoded.isNewUser) // flags are never encoded @@ -33,19 +34,20 @@ final class AccountDetailsTests: XCTestCase { "GenderIdentityKey": AccountKeys.genderIdentity ] - let encoder = JSONEncoder() - encoder.userInfo[.accountKeyIdentifierMapping] = mapping + let encodingConfiguration = AccountDetails.EncodingConfiguration(identifierMapping: mapping) + let decodingConfiguration = AccountDetails.DecodingConfiguration( + keys: [AccountKeys.genderIdentity], + identifierMapping: mapping + ) + let encoder = JSONEncoder() let decoder = JSONDecoder() - decoder.userInfo[.accountDetailsKeys] = [AccountKeys.genderIdentity] - decoder.userInfo[.accountKeyIdentifierMapping] = mapping - - let data = try encoder.encode(details) + let data = try encoder.encode(details, configuration: encodingConfiguration) let string = try XCTUnwrap(String(data: data, encoding: .utf8)) XCTAssertEqual(string, "{\"GenderIdentityKey\":\"female\"}") - let decoded = try decoder.decode(AccountDetails.self, from: data) + let decoded = try decoder.decode(AccountDetails.self, from: data, configuration: decodingConfiguration) XCTAssertEqual(decoded.genderIdentity, details.genderIdentity) }