diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ad24580..daa2c81 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,6 @@ on: jobs: build_and_test: name: Build and Test - uses: Apodini/.github/.github/workflows/build-and-test.yml@main + uses: Apodini/.github/.github/workflows/build-and-test.yml@v1 with: packagename: MetadataSystem - xcodebuildpostfix: -Package diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index ab1d498..ee1e593 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -15,13 +15,12 @@ on: jobs: build_and_test: name: Build and Test - uses: Apodini/.github/.github/workflows/build-and-test.yml@main + uses: Apodini/.github/.github/workflows/build-and-test.yml@v1 with: packagename: MetadataSystem - xcodebuildpostfix: -Package reuse_action: name: REUSE Compliance Check - uses: Apodini/.github/.github/workflows/reuse.yml@main + uses: Apodini/.github/.github/workflows/reuse.yml@v1 swiftlint: name: SwiftLint - uses: Apodini/.github/.github/workflows/swiftlint.yml@main + uses: Apodini/.github/.github/workflows/swiftlint.yml@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8746923..55622e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,6 @@ on: jobs: docs: name: Generate Docs - uses: Apodini/.github/.github/workflows/docs.yml@main + uses: Apodini/.github/.github/workflows/docs.yml@v1 with: packagename: MetadataSystem \ No newline at end of file diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml index 51fe1b2..a416e4c 100644 --- a/.github/workflows/update.yml +++ b/.github/workflows/update.yml @@ -16,6 +16,6 @@ on: jobs: spm_update: name: Swift Package Update - uses: Apodini/.github/.github/workflows/spm-update.yml@main + uses: Apodini/.github/.github/workflows/spm-update.yml@v1 secrets: token: ${{ secrets.ACCESS_TOKEN }} diff --git a/Sources/ApodiniContext/CodableContextKey.swift b/Sources/ApodiniContext/CodableContextKey.swift new file mode 100644 index 0000000..d8b398f --- /dev/null +++ b/Sources/ApodiniContext/CodableContextKey.swift @@ -0,0 +1,43 @@ +// +// This source file is part of the Apodini open source project +// +// SPDX-FileCopyrightText: 2019-2022 Paul Schmiedmayer and the Apodini project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + +/// Type erased ``CodableContextKey``. +public protocol AnyCodableContextKey: AnyContextKey { + static var identifier: String { get } + + static func anyEncode(value: Any) throws -> Data +} + +/// An ``OptionalContextKey`` which value is able to be encoded and decoded. +public protocol CodableContextKey: AnyCodableContextKey, OptionalContextKey where Value: Codable { + static func decode(from data: Data) throws -> Value +} + +// Data, by default, is encoded as base64 +private let encoder = JSONEncoder() +private let decoder = JSONDecoder() + +extension CodableContextKey { + public static var identifier: String { + "\(Self.self)" + } + + public static func anyEncode(value: Any) throws -> Data { + guard let value = value as? Self.Value else { + fatalError("CodableContextKey.anyEncode(value:) received illegal value type \(type(of: value)) instead of \(Value.self)") + } + + return try encoder.encode(value) + } + + public static func decode(from data: Data) throws -> Value { + try decoder.decode(Value.self, from: data) + } +} diff --git a/Sources/ApodiniContext/Context.swift b/Sources/ApodiniContext/Context.swift index e47a392..a212a7d 100644 --- a/Sources/ApodiniContext/Context.swift +++ b/Sources/ApodiniContext/Context.swift @@ -6,13 +6,33 @@ // SPDX-License-Identifier: MIT // +import Foundation + +private class ContextBox { + var entries: [ObjectIdentifier: StoredContextValue] + + init(_ entries: [ObjectIdentifier: StoredContextValue]) { + self.entries = entries + } +} + +struct StoredContextValue { + let key: AnyContextKey.Type + let value: Any +} + /// Defines some sort of `Context` for a given representation (like `Endpoint`). /// A `Context` holds a collection of values for predefined `ContextKey`s or `OptionalContextKey`s. public struct Context: ContextKeyRetrievable { - private let entries: [ObjectIdentifier: Any] + private var boxedEntries: ContextBox + private var entries: [ObjectIdentifier: StoredContextValue] { + boxedEntries.entries + } + private let decodedEntries: [String: Data] - init(_ entries: [ObjectIdentifier: Any] = [:]) { - self.entries = entries + init(_ entries: [ObjectIdentifier: StoredContextValue] = [:], _ decodedEntries: [String: Data] = [:]) { + self.boxedEntries = ContextBox(entries) + self.decodedEntries = decodedEntries } /// Create a new empty ``Context``. @@ -22,14 +42,14 @@ public struct Context: ContextKeyRetrievable { /// Creates a new ``Context`` by copying the contents of the provided ``Context``. public init(copying context: Context) { - self.entries = context.entries + self.init(context.entries, context.decodedEntries) } /// Retrieves the value for a given `ContextKey`. /// - Parameter contextKey: The `ContextKey` to retrieve the value for. /// - Returns: Returns the stored value or the `ContextKey.defaultValue` if it does not exist on the given `Context`. public func get(valueFor contextKey: C.Type = C.self) -> C.Value { - entries[ObjectIdentifier(contextKey)] as? C.Value + entries[ObjectIdentifier(contextKey)]?.value as? C.Value ?? C.defaultValue } @@ -37,7 +57,58 @@ public struct Context: ContextKeyRetrievable { /// - Parameter contextKey: The `OptionalContextKey` to retrieve the value for. /// - Returns: Returns the stored value or `nil` if it does not exist on the given `Context`. public func get(valueFor contextKey: C.Type = C.self) -> C.Value? { - entries[ObjectIdentifier(contextKey)] as? C.Value + entries[ObjectIdentifier(contextKey)]?.value as? C.Value + } + + /// Retrieves the value for a given `CodableContextKey`. + /// - Parameter contextKey: The `OptionalContextKey` to retrieve the value for. + /// - Returns: Returns the stored value or `nil` if it does not exist on the given `Context`. + public func get(valueFor contextKey: C.Type = C.self) -> C.Value? { + entries[ObjectIdentifier(contextKey)]?.value as? C.Value + ?? checkForDecodedEntries(for: contextKey) + } + + /// Retrieves the value for a given `ContextKey & CodableContextKey`. + /// - Parameter contextKey: The `ContextKey` to retrieve the value for. + /// - Returns: Returns the stored value or the `ContextKey.defaultValue` if it does not exist on the given `Context`. + public func get(valueFor contextKey: C.Type = C.self) -> C.Value { + entries[ObjectIdentifier(contextKey)]?.value as? C.Value + ?? checkForDecodedEntries(for: contextKey) + ?? C.defaultValue + } + + /// This method can be used to unsafely add new entries to a constructed ``Context``. + /// This method is considered unsafe as it changes the ``Context`` which is normally considered non-mutable. + /// Try to not used this method! + /// + /// Note: This method does NOT reduce multiple values for the same key. You cannot add a value + /// if there is already a value for the given context key! + /// + /// - Parameters: + /// - contextKey: The context to add value for. + /// - value: The value to add. + public func unsafeAdd(_ contextKey: C.Type = C.self, value: C.Value) { + let key = ObjectIdentifier(contextKey) + + precondition(entries[key] == nil, "Cannot overwrite existing ContextKey entry with `unsafeAdd`: \(C.self): \(value)") + if let codableContextKey = contextKey as? AnyCodableContextKey.Type { + // we need to prevent this. as Otherwise we would need to handle merging this stuff which get really complex + precondition(decodedEntries[codableContextKey.identifier] == nil, "Cannot overwrite existing CodableContextKey entry with `unsafeAdd`: \(C.self): \(value)") + } + + boxedEntries.entries[key] = StoredContextValue(key: contextKey, value: value) + } + + private func checkForDecodedEntries(for key: Key.Type = Key.self) -> Key.Value? { + guard let dataValue = decodedEntries[Key.identifier] else { + return nil + } + + do { + return try Key.decode(from: dataValue) + } catch { + fatalError("Error occurred when trying to decode `CodableContextKey` `\(Key.self)` with stored value '\(dataValue)': \(error)") + } } } @@ -46,3 +117,55 @@ extension Context: CustomStringConvertible { "Context(entries: \(entries))" } } + +// MARK: LazyCodable +extension Context: Codable { + private struct StringContextKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + + init(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { + nil + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: StringContextKey.self) + + self.boxedEntries = ContextBox([:]) + + var decodedEntries: [String: Data] = [:] + + for key in container.allKeys { + decodedEntries[key.stringValue] = try container.decode(Data.self, forKey: key) + } + + self.decodedEntries = decodedEntries + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: StringContextKey.self) + + var entries: [String: Data] = [:] + + for storedValue in self.entries.values { + guard let contextKey = storedValue.key as? AnyCodableContextKey.Type else { + continue + } + + entries[contextKey.identifier] = try contextKey.anyEncode(value: storedValue.value) + } + + entries.merge(self.decodedEntries) { current, new in + fatalError("Encountered context value conflicts of \(current) and \(new)!") + } + + for (key, data) in entries { + try container.encode(data, forKey: StringContextKey(stringValue: key)) + } + } +} diff --git a/Sources/ApodiniContext/ContextEntry.swift b/Sources/ApodiniContext/ContextEntry.swift index 2414db2..d6ef7f1 100644 --- a/Sources/ApodiniContext/ContextEntry.swift +++ b/Sources/ApodiniContext/ContextEntry.swift @@ -37,7 +37,7 @@ protocol AnyContextEntry { /// Reduces all collected context keys according to the respective `OptionalContextKey.reduce(...)`. /// - Returns: Returns the reduced value of the respective `OptionalContextKey.Value` type. - func reduce() -> Any + func reduce() -> StoredContextValue /// Creates a new `ContextEntryCollection` with expected generic typing. /// - Parameters: @@ -86,7 +86,7 @@ class ContextEntry: AnyContextEntry { return ContextEntry(lhsValues + selfRHS.values) } - func reduce() -> Any { + func reduce() -> StoredContextValue { guard var value = values.first?.value else { // we guarantee in the initializer that values won't ever be empty fatalError("Found inconsistency. \(type(of: self)) was found with empty values array.") @@ -104,7 +104,7 @@ class ContextEntry: AnyContextEntry { Key.mapFinal(value: &value) - return value + return StoredContextValue(key: Key.self, value: value) } func deriveCollection(entry: AnyContextEntry, derivedFromModifier: Bool) -> AnyContextEntryCollection { diff --git a/Sources/ApodiniContext/ContextKey.swift b/Sources/ApodiniContext/ContextKey.swift index f0ab5af..0872459 100644 --- a/Sources/ApodiniContext/ContextKey.swift +++ b/Sources/ApodiniContext/ContextKey.swift @@ -1,17 +1,20 @@ -// +// // This source file is part of the Apodini open source project // -// SPDX-FileCopyrightText: 2019-2021 Paul Schmiedmayer and the Apodini project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2019-2022 Paul Schmiedmayer and the Apodini project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT -// +// + +/// Type erased ContextKey. +public protocol AnyContextKey {} /// A `OptionalContextKey` serves as a key definition for a `ContextNode`. /// Optionally it can serve a reduction logic when inserting a new value into the `ContextNode`, /// see `OptionalContextKey.reduce(...)`. /// The `OptionalContextKey` is optional in the sense that it doesn't provide a default value, meaning /// it may not exist on the `Context` for a given `Handler`. -public protocol OptionalContextKey { +public protocol OptionalContextKey: AnyContextKey { /// The type of the value the `OptionalContextKey` identifies. The value MUST NOT be of type `Optional`. associatedtype Value diff --git a/Sources/ApodiniContext/ContextNode.swift b/Sources/ApodiniContext/ContextNode.swift index 0cef02c..39c9421 100644 --- a/Sources/ApodiniContext/ContextNode.swift +++ b/Sources/ApodiniContext/ContextNode.swift @@ -92,7 +92,6 @@ public class ContextNode { precondition(exportedEntries == nil, "Tried adding additional context values on a ContextNode which was already exported!") guard !(C.Value.self is SomeOptional.Type) else { - // guard !isOptional(C.Value.self) else { fatalError( """ The `Value` type of a `ContextKey` or `OptionalContextKey` must not be a `Optional` type. @@ -197,7 +196,7 @@ public class ContextNode { /// retrieved when everything was fully parsed. /// `peekValue` doesn't guarantee that. The value might change after the call as parsing continues. public func peekValue(for contextKey: C.Type = C.self) -> C.Value { - peekExportEntry(for: contextKey)?.reduce() as? C.Value + peekExportEntry(for: contextKey)?.reduce().value as? C.Value ?? C.defaultValue } @@ -206,7 +205,7 @@ public class ContextNode { /// retrieved when everything was fully parsed. /// `peekValue` doesn't guarantee that. The value might change after the call as parsing continues. public func peekValue(for contextKey: C.Type = C.self) -> C.Value? { - peekExportEntry(for: contextKey)?.reduce() as? C.Value + peekExportEntry(for: contextKey)?.reduce().value as? C.Value } } diff --git a/Tests/ApodiniContextTests/ContextKeyTests.swift b/Tests/ApodiniContextTests/ContextKeyTests.swift index 5dbee60..f169ce0 100644 --- a/Tests/ApodiniContextTests/ContextKeyTests.swift +++ b/Tests/ApodiniContextTests/ContextKeyTests.swift @@ -72,9 +72,36 @@ class ContextKeyTests: XCTestCase { } func testContextKeyCopying() { - let context = Context([ObjectIdentifier(String.self): "asdf"]) + let context = Context([ObjectIdentifier(String.self): .init(key: StringContextKey.self, value: "asdf")]) let copied = Context(copying: context) XCTAssertEqual(context.description, copied.description) } + + func testCodableSupportAndUnsafeAdd() throws { + struct CodableStringContextKey: CodableContextKey { + typealias Value = String + } + + struct RequiredCodableStringContextKey: CodableContextKey, ContextKey { + static var defaultValue: String = "Default Value!" + typealias Value = String + } + + let context = Context() + context.unsafeAdd(CodableStringContextKey.self, value: "Hello World") + XCTAssertRuntimeFailure(context.unsafeAdd(CodableStringContextKey.self, value: "Hello Mars")) + + XCTAssertEqual(context.get(valueFor: CodableStringContextKey.self), "Hello World") + XCTAssertEqual(context.get(valueFor: RequiredCodableStringContextKey.self), "Default Value!") + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let encodedContext = try encoder.encode(context) + let decodedContext = try decoder.decode(Context.self, from: encodedContext) + + XCTAssertEqual(decodedContext.get(valueFor: CodableStringContextKey.self), "Hello World") + XCTAssertEqual(decodedContext.get(valueFor: RequiredCodableStringContextKey.self), "Default Value!") + } }