diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..85a54a1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + pull_request: + push: + branches: + - trunk + +permissions: + contents: read + +jobs: + linux: + name: Linux Swift 6.3 + runs-on: ubuntu-latest + container: swift:6.3.1 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Swift Version + run: swift --version + + - name: Test + run: swift test + + macos: + name: macOS 26 + runs-on: macos-26 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Swift Version + run: swift --version + + - name: Test + run: swift test diff --git a/Package.swift b/Package.swift index c23ca8c..cce8544 100644 --- a/Package.swift +++ b/Package.swift @@ -1,15 +1,21 @@ -// swift-tools-version: 5.6 +// swift-tools-version: 6.3 import PackageDescription let package = Package( name: "AnyCoding", - platforms: [.iOS(.v13), .macOS(.v10_15)], + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .custom("android", versionString: "28.0"), + .custom("linux", versionString: "1.0") + ], products: [ .library(name: "AnyCoding", targets: ["AnyCoding"]) ], targets: [ .target(name: "AnyCoding"), .testTarget(name: "AnyCodingTests", dependencies: ["AnyCoding"]) - ] + ], + swiftLanguageModes: [.v6] ) diff --git a/README.md b/README.md index 7726d2d..3486638 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,148 @@ # AnyCoding -Custom `Decoder` and `Encoder` types for when you are working with `Any` +AnyCoding is a small Swift package for moving directly between `Codable` values and dynamic `Any` trees. -## AnyDecoder +It is useful when a boundary already gives you dictionaries, arrays, fragments or `JSONSerialization` output, but the rest of your code wants normal Swift models. The package provides custom `Encoder` and `Decoder` implementations that walk those values in memory instead of serializing them into `Data` just so `JSONDecoder` can parse them again. -Decode `Any` into concrete `T` +## Why -## AnyEncoder +Swift's `Codable` APIs are strongest when both sides know the concrete type. Many integration points are less tidy: -Encode concrete `T` into `Any` +- `JSONSerialization` returns `[String: Any]`, `[Any]` and fragments. +- Plugin, scripting and rule systems often pass dynamic values around. +- Tests and fixtures are easier to write as dictionary literals. +- Some app code needs to inspect, edit and then decode a payload. -## EmptyDecoder +AnyCoding keeps those boundaries small and cheap. Decode dynamic input into concrete models when you can, encode models back to dynamic values when you need to, and use `AnyJSON` when the shape is intentionally fluid. -Decode `T` from nothing - - ### Notes - - `Any` refers to a tree-like structure using dictionary and array types +## Why Not JSONEncoder and JSONDecoder? + +`JSONEncoder` and `JSONDecoder` are the right tools when the boundary is bytes: files, network bodies, logs and other places where JSON text is the format you need to exchange. + +They are awkward when the value is already an in-memory tree. A `[String: Any]`, `[Any]` or fragment has to be serialized into `Data` before it can be decoded, and an encoded model has to be parsed back out of `Data` before it can be inspected, edited or passed to APIs that expect dynamic values. That extra hop costs work and makes dynamic traversal harder to express. + +AnyCoding is for the in-memory case: + +- decode directly from dictionaries, arrays, fragments and `JSONSerialization` output. +- encode directly into dictionaries, arrays, fragments and `NSNull`. +- traverse and edit dynamic values before choosing a concrete type. +- convert parts of a payload without decoding the whole value up front. +- add domain-specific conversions by subclassing `AnyDecoder` and overriding `convert`. + +Swift Compute uses this shape for its `_JSON` package: typed values can be encoded into a JSON value through `AnyEncoder`, and JSON values can be decoded back into models through `AnyDecoder`, without first round-tripping through `Data`. + +BlockchainNamespace uses the same extension point for domain decoding. Its custom decoder converts strings, tags and references into domain types while still falling back to the standard AnyCoding conversions for ordinary JSON-shaped values. + +## What It Provides + +- `AnyDecoder`: decodes `Any` trees into `Decodable` values without a `Data` round trip. +- `AnyEncoder`: encodes `Encodable` values into `Any` trees without producing JSON bytes. +- `AnyJSON`: a lightweight dynamic wrapper with literal support, path access and Codable bridging. +- `EmptyDecoder`: creates empty/default instances of `Decodable` values. +- Path subscripting helpers for dictionaries, arrays and optional `Any` values. + +## Installation + +Add the package to your `Package.swift` dependencies: + +```swift +.package(url: "https://github.com/thousandyears/AnyCoding.git", branch: "trunk") +``` + +Then add `AnyCoding` to your target dependencies: + +```swift +.product(name: "AnyCoding", package: "AnyCoding") +``` + +## Examples + +Decode an untyped payload into a concrete model without first serializing it: + +```swift +import AnyCoding +import Foundation + +struct Profile: Codable, Equatable { + var name: String + var age: Int + var website: URL +} + +let payload: [String: Any] = [ + "name": "Ada", + "age": 36, + "website": "https://example.com" +] + +let profile = try AnyDecoder().decode(Profile.self, from: payload) +``` + +Encode a concrete model back into an inspectable `Any` tree: + +```swift +let encoded = try AnyEncoder().encode(profile) + +// encoded is a [String: Any] tree containing String, Int and URL-as-String values. +// It can be inspected or edited immediately; there are no JSON bytes to parse first. +``` + +Use `AnyJSON` when the shape is dynamic and you want path access before typed decoding: + +```swift +var json: AnyJSON = [ + "profile": [ + "name": "Ada", + "tags": ["maths", "computing"] + ] +] + +let name = try json.profile.name.as(String.self) +let tags: [String] = try json.profile.tags.decode(using: AnyDecoder()) +``` + +Create empty values for tests, placeholders or progressive construction: + +```swift +let empty = try EmptyDecoder().decode(Profile.self) +``` + +## Conversion Rules + +`AnyEncoder` and `AnyDecoder` include practical conversions while walking the `Any` tree: + +- `URL` encodes as its absolute string and decodes from a string. +- `Date` encodes as seconds since 1970 and decodes from a time interval. +- `Data` can decode from JSON data using `JSONSerialization`. +- `NSNumber` can decode into standard numeric and boolean types. +- `RawRepresentable` values can decode from their raw value. +- `nil` and `NSNull` are treated as null-like values. + +## Platforms + +AnyCoding uses Swift 6.3 and supports macOS 10.15+, iOS 13+, Android and Linux. + +The package conditionally exposes Combine's `TopLevelEncoder` and `TopLevelDecoder` conformances on platforms where Combine is available. Core decoding and encoding does not require Combine. + +CI runs the SwiftPM test suite on Linux and macOS 26. Android support is kept buildable, but Android is not part of the default pull-request CI. + +## Development + +Run the test suite with: + +```sh +swift test +``` + +Useful platform checks: + +```sh +swift build --target AnyCoding --triple arm64-apple-ios18.0 --sdk "$(xcrun --sdk iphoneos --show-sdk-path)" +swift build --target AnyCoding --swift-sdk aarch64-unknown-linux-android28 --static-swift-stdlib +``` + +## Philosophy + +AnyCoding is deliberately small. It is not a new JSON model, a schema system or a replacement for `Codable`. + +The aim is to avoid treating `Data` as an artificial checkpoint. If your real boundary is JSON text, use `JSONEncoder` and `JSONDecoder`. If your real boundary is already an `Any` tree, AnyCoding lets you traverse, convert and return to typed Swift without paying for a stringify-and-parse cycle. diff --git a/Sources/AnyCoding/AnyDecoder.swift b/Sources/AnyCoding/AnyDecoder.swift index cd8bd52..e814d7f 100644 --- a/Sources/AnyCoding/AnyDecoder.swift +++ b/Sources/AnyCoding/AnyDecoder.swift @@ -1,5 +1,9 @@ +#if canImport(Combine) import Combine +#endif +#if canImport(CoreGraphics) import CoreGraphics +#endif import Foundation public protocol AnyDecoderProtocol: AnyObject, Decoder { @@ -22,7 +26,7 @@ extension AnyDecoderProtocol { } } -open class AnyDecoder: AnyDecoderProtocol, TopLevelDecoder { +open class AnyDecoder: AnyDecoderProtocol { public var codingPath: [CodingKey] = [] public var userInfo: [CodingUserInfoKey: Any] = [:] @@ -82,8 +86,10 @@ open class AnyDecoder: AnyDecoderProtocol, TopLevelDecoder { return number.floatValue case (let number as NSNumber, is Double.Type): return number.doubleValue + #if canImport(CoreGraphics) case (let number as NSNumber, is CGFloat.Type): - return number.doubleValue + return CGFloat(number.doubleValue) + #endif case (let boolean as Bool, is String.Type): return boolean.description case (let string as String, is Int.Type): @@ -105,6 +111,10 @@ open class AnyDecoder: AnyDecoderProtocol, TopLevelDecoder { } } +#if canImport(Combine) +extension AnyDecoder: TopLevelDecoder {} +#endif + extension AnyDecoder { public struct Error: Swift.Error, LocalizedError, Equatable { diff --git a/Sources/AnyCoding/AnyEncoder.swift b/Sources/AnyCoding/AnyEncoder.swift index 7c3b5d0..ed05e38 100644 --- a/Sources/AnyCoding/AnyEncoder.swift +++ b/Sources/AnyCoding/AnyEncoder.swift @@ -1,4 +1,6 @@ +#if canImport(Combine) import Combine +#endif import Foundation public protocol AnyEncoderProtocol: AnyObject, Encoder { @@ -12,7 +14,7 @@ public protocol AnyEncoderProtocol: AnyObject, Encoder { func encode(_ this: T) throws -> Any? where T: Encodable } -open class AnyEncoder: AnyEncoderProtocol, TopLevelEncoder { +open class AnyEncoder: AnyEncoderProtocol { public var codingPath: [CodingKey] = [] public var userInfo: [CodingUserInfoKey: Any] = [:] @@ -55,6 +57,10 @@ open class AnyEncoder: AnyEncoderProtocol, TopLevelEncoder { } } +#if canImport(Combine) +extension AnyEncoder: TopLevelEncoder {} +#endif + extension AnyEncoder { public func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key: CodingKey { diff --git a/Sources/AnyCoding/AnyJSON.swift b/Sources/AnyCoding/AnyJSON.swift index f9b3306..cd7932b 100644 --- a/Sources/AnyCoding/AnyJSON.swift +++ b/Sources/AnyCoding/AnyJSON.swift @@ -2,25 +2,25 @@ import Foundation @dynamicMemberLookup public struct AnyJSON: Codable, Hashable, Equatable, CustomStringConvertible { - + public typealias Error = String.Error - + public private(set) var wrapped: Any public var any: Any { wrapped } - + public var value: Any { get { wrapped } set { wrapped = newValue } } - + var __unwrapped: Any { (wrapped as? AnyJSON)?.__unwrapped ?? wrapped } - + public init() { self = nil } - + public init(_ any: Any) { switch any { case let thing as AnyJSON: @@ -29,58 +29,58 @@ public struct AnyJSON: Codable, Hashable, Equatable, CustomStringConvertible { self.wrapped = any } } - + public init(_ any: Any?) { self.init(any ?? NSNull()) } - + private var __subscript: Any? { get { wrapped } set { wrapped = newValue ?? NSNull() } } - + public subscript(dynamicMember keyPath: KeyPath) -> T? { __subscript[keyPath: keyPath] } - + public subscript(dynamicMember string: String) -> AnyJSON { get { AnyJSON(self[AnyCodingKey(string)]) } set { self[AnyCodingKey(string)] = newValue.__unwrapped } } - + public subscript(index: String) -> AnyJSON { get { AnyJSON(self[AnyCodingKey(index)]) } set { self[AnyCodingKey(index)] = newValue.__unwrapped } } - + public subscript(index: Int) -> AnyJSON { get { AnyJSON(self[AnyCodingKey(index)]) } set { self[AnyCodingKey(index)] = newValue.__unwrapped } } - + public subscript(first: AnyCodingKey, rest: AnyCodingKey...) -> Any? { get { __subscript[[first] + rest] } set { __subscript[[first] + rest] = newValue } } - + public subscript(path: some Collection) -> Any? { get { __subscript[path] } set { __subscript[path] = newValue } } - + public subscript(path: some Collection) -> Any? { get { __subscript[path] } set { __subscript[path] = newValue } } - + public func hash(into hasher: inout Hasher) { (wrapped as? AnyHashable).hash(into: &hasher) } - + public static func == (lhs: Self, rhs: Self) -> Bool { isEqual(lhs.__unwrapped, rhs.__unwrapped) } - + public init(from decoder: Decoder) throws { switch decoder { case _ as EmptyDecoder: @@ -118,7 +118,7 @@ public struct AnyJSON: Codable, Hashable, Equatable, CustomStringConvertible { ) } } - + public func encode(to encoder: Encoder) throws { switch encoder { case let encoder as ContainerTypeEncoder: @@ -146,19 +146,19 @@ public struct AnyJSON: Codable, Hashable, Equatable, CustomStringConvertible { ) } } - + public func `as`(_ type: T.Type) throws -> T { try (wrapped as? T).or(throw: Error("Cannot cast \(Swift.type(of: wrapped)) to \(T.self)")) } - + public var description: String { any as? String ?? (any as? CustomStringConvertible)?.description ?? String(describing: any) } - + public func dictionary() -> [String: Any]? { wrapped as? [String: Any] } - + public func array() -> [Any]? { wrapped as? [Any] } @@ -170,7 +170,7 @@ public struct AnyJSON: Codable, Hashable, Equatable, CustomStringConvertible { return try JSONSerialization.data(withJSONObject: value, options: options) } - public static let empty: AnyJSON = nil + public static var empty: AnyJSON { nil } } extension AnyJSON: ExpressibleByNilLiteral { @@ -222,13 +222,13 @@ extension AnyJSON: ExpressibleByStringInterpolation { } extension AnyJSON { - + @inlinable public var isNil: Bool { value is NSNull || (value as? any OptionalProtocol)?.isNil ?? false } @inlinable public var isNotNil: Bool { !isNil } - + @inlinable public var isEmpty: Bool { (value as? any Collection)?.isEmpty ?? false } @inlinable public var isNotEmpty: Bool { !isEmpty } - + @_disfavoredOverload @inlinable public func decode(_: T.Type = T.self, using decoder: AnyDecoderProtocol) throws -> T { try decoder.decode(T.self, from: wrapped) @@ -240,7 +240,7 @@ public protocol AnyJSONConvertible { } extension AnyJSON { - + public func data(using encoder: AnyEncoderProtocol = AnyEncoder()) throws -> Data { let value = try encoder.encode(self) ?? NSNull() guard JSONSerialization.isValidJSONObject([value]) else { @@ -251,13 +251,13 @@ extension AnyJSON { } extension AnyJSON { - + public var isNotError: Bool { !isError } public var isError: Bool { if value is String { return false } return value is Swift.Error } - + public func throwIfError() throws -> AnyJSON { if !(any is String), let error = any as? Swift.Error { throw error } return self @@ -265,7 +265,7 @@ extension AnyJSON { } extension Any? { - + public func throwIfError() throws -> Any? { if !(self is String), let error = self as? Swift.Error { throw error } return self diff --git a/Sources/AnyCoding/util/Equatable.swift b/Sources/AnyCoding/util/Equatable.swift index c95c5eb..8e8bae4 100644 --- a/Sources/AnyCoding/util/Equatable.swift +++ b/Sources/AnyCoding/util/Equatable.swift @@ -1,4 +1,4 @@ -public func isEqual(_ x: Any, _ y: Any) -> Bool { +func isEqual(_ x: Any, _ y: Any) -> Bool { if let isEqual = (x as? any Equatable)?.isEqual(to: y) { return isEqual } else if let equatable = x as? AnyEquatable { diff --git a/Tests/AnyCodingTests/AnyCodingTests.swift b/Tests/AnyCodingTests/AnyCodingTests.swift index 1558e94..994d1a6 100644 --- a/Tests/AnyCodingTests/AnyCodingTests.swift +++ b/Tests/AnyCodingTests/AnyCodingTests.swift @@ -93,7 +93,7 @@ final class AnyCodingTests: XCTestCase { assertCoding(["1": value, "2": value]) } - func assertCoding(_ value: T, _ file: StaticString = #file, _ line: UInt = #line) { + func assertCoding(_ value: T, _ file: StaticString = #filePath, _ line: UInt = #line) { do { let encoded = try XCTUnwrap(AnyEncoder().encode(value), file: file, line: line) let decoded = try AnyDecoder().decode(T.self, from: encoded)