Skip to content

Commit

Permalink
Encoding and Decoding of AccountDetails with strongly typed configura…
Browse files Browse the repository at this point in the history
…tion (#69)

# Encoding and Decoding of AccountDetails with strongly typed
configuration

## ♻️ Current situation & Problem
The current approach of decoding and encoding `AccountDetails` relies on
setting the expected `userInfo` keys on the encoder and decoder
instance. While is highlighted in documentation, there is no
compile-time check that there isn't any mistake in the configuration
(e.g., missing keys or values with unexpected type). iOS 17 introduced
the `CodableWithConfiguration` protocols that allow to set a
configuration type for encoding and decoding.
This PR migrates the current approach to adopting the
`EncodableWithConfiguration` protocol and replacing the `Decodable`
conformance with `DecodableWithConfiguration`. The requirement to pass a
configuration upon decoding is now expressed with the type system and
will produce a compiler warning if not done correctly.

## ⚙️ Release Notes 
* Add `EncodingConfiguration` and `DecodingConfiguration` for
AccountDetails and conformance to `CodableWithConfiguration`. The
`Decodable` conformance was dropped.


## 📚 Documentation
Documentation was added for new interfaces and updated to point to the
new configuration types.


## ✅ Testing
Existing unit test verify that the new implementation doesn't break any
expected behavior.


## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
Supereg authored Aug 28, 2024
1 parent 40fa3f5 commit 6b1603d
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 141 deletions.
6 changes: 3 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
4 changes: 3 additions & 1 deletion Sources/SpeziAccount/AccountDetailsCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
// SPDX-License-Identifier: MIT
//

import Foundation


extension AccountDetails {
/// Use an `AccountKey` as a `CodingKey`.
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -264,7 +264,7 @@ extension AccountDetails: SendableSharedRepository {
public func get<Source: KnowledgeSource<Anchor>>(_ source: Source.Type) -> Source.Value? where Source.Value: Sendable {
storage.get(source)
}

public mutating func set<Source: KnowledgeSource<Anchor>>(_ source: Source.Type, value newValue: Source.Value?) where Source.Value: Sendable {
storage.set(source, value: newValue)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Loading

0 comments on commit 6b1603d

Please sign in to comment.