Skip to content

Commit

Permalink
Add Codable support for Context and unsafe add functionality (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
Supereg authored Jan 31, 2022
1 parent 2f3f03f commit 7f0df17
Show file tree
Hide file tree
Showing 10 changed files with 218 additions and 25 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 3 additions & 4 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
43 changes: 43 additions & 0 deletions Sources/ApodiniContext/CodableContextKey.swift
Original file line number Diff line number Diff line change
@@ -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) <paul.schmiedmayer@tum.de>
//
// 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)
}
}
135 changes: 129 additions & 6 deletions Sources/ApodiniContext/Context.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand All @@ -22,22 +42,73 @@ 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<C: ContextKey>(valueFor contextKey: C.Type = C.self) -> C.Value {
entries[ObjectIdentifier(contextKey)] as? C.Value
entries[ObjectIdentifier(contextKey)]?.value as? C.Value
?? C.defaultValue
}

/// Retrieves the value for a given `OptionalContextKey`.
/// - 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<C: OptionalContextKey>(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<C: CodableContextKey>(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<C: ContextKey & CodableContextKey>(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<C: OptionalContextKey>(_ 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<Key: CodableContextKey>(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)")
}
}
}

Expand All @@ -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))
}
}
}
6 changes: 3 additions & 3 deletions Sources/ApodiniContext/ContextEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -86,7 +86,7 @@ class ContextEntry<Key: OptionalContextKey>: 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.")
Expand All @@ -104,7 +104,7 @@ class ContextEntry<Key: OptionalContextKey>: AnyContextEntry {

Key.mapFinal(value: &value)

return value
return StoredContextValue(key: Key.self, value: value)
}

func deriveCollection(entry: AnyContextEntry, derivedFromModifier: Bool) -> AnyContextEntryCollection {
Expand Down
11 changes: 7 additions & 4 deletions Sources/ApodiniContext/ContextKey.swift
Original file line number Diff line number Diff line change
@@ -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) <paul.schmiedmayer@tum.de>
// SPDX-FileCopyrightText: 2019-2022 Paul Schmiedmayer and the Apodini project authors (see CONTRIBUTORS.md) <paul.schmiedmayer@tum.de>
//
// 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

Expand Down
5 changes: 2 additions & 3 deletions Sources/ApodiniContext/ContextNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<C: ContextKey>(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
}

Expand All @@ -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<C: OptionalContextKey>(for contextKey: C.Type = C.self) -> C.Value? {
peekExportEntry(for: contextKey)?.reduce() as? C.Value
peekExportEntry(for: contextKey)?.reduce().value as? C.Value
}
}

Expand Down
29 changes: 28 additions & 1 deletion Tests/ApodiniContextTests/ContextKeyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!")
}
}

0 comments on commit 7f0df17

Please sign in to comment.