Skip to content

Commit

Permalink
Merge pull request #351 from mattpolzin/any-codable-small-improvements
Browse files Browse the repository at this point in the history
Improvements to AnyCodable equality and ability to Encode any Encodable.
  • Loading branch information
mattpolzin authored Dec 3, 2023
2 parents 3545fc2 + 00144b2 commit 0629b7e
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 0 deletions.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,9 @@ Many OpenAPIKit types support [Specification Extensions](https://github.com/OAI/

You can get or set specification extensions via the `vendorExtensions` property on any object that supports this feature. The keys are `Strings` beginning with the aforementioned "x-" prefix and the values are `AnyCodable`. If you set an extension without using the "x-" prefix, the prefix will be added upon encoding.

#### AnyCodable
OpenAPIKit uses the `AnyCodable` type for vendor extensions and constructing examples for JSON Schemas. OpenAPIKit's `AnyCodable` type is an adaptation of the Flight School library that can be found [here](https://github.com/Flight-School/AnyCodable).

`AnyCodable` can be constructed from literals or explicitly. The following are all valid.

```swift
Expand All @@ -300,6 +303,25 @@ document.vendorExtensions["x-specialProperty4"] = ["hello": "world"]
document.vendorExtensions["x-specialProperty5"] = AnyCodable("hello world")
```

It is important to note that `AnyCodable` wraps Swift types in a way that keeps track of the Swift type used to construct it as much as possible, but if you encode an `AnyCodable` and then decode that result, the decoded value may not always be the same as the pre-encoded value started out. This is because many Swift types will encode to "stringy" values and then decode as simply `String` values. There are two ways to cope with this:
1. When adding stringy values to structures that will be passed to `AnyCodable`, you can explicitly turn them into `String`s. For example, you can use `URL(...).absoluteString` both to specify you want the absolute value of the URL encoded and also to turn it into a `String` up front.
2. When comparing `AnyCodable` values that have passed through a full encode/decode cycle, you can compare the `description` of the two `AnyCodable` values. This stringy result is _more likely_ to compare equivalently.

Keep in mind, this issue only occurs when you are comparing value `a` and value `b` for equality given that `b` is `a` after being encoded and then subsequently decoded.

The other sure-fire way to handle this (if you need encode-decode equality, not just equivalence) is to make sure you run both values being compared through encoding. For example, you might use the following function which doesn't even care if the input is `AnyCodable` or not:
```swift
func encodedEqual<A: Codable, B: Codable>(_ a: A, _ b: B) throws -> Bool {
let a = try JSONEncoder().encode(a)
let b = try JSONEncoder().encode(b)
return a == b
}
```
For example, the result of the following is `true`:
```swift
try encodeEqual(URL(string: "https://website.com"), AnyCodable(URL(string: "https://website.com")))
```

### Dereferencing & Resolving
In addition to looking something up in the `Components` object, you can entirely derefererence many OpenAPIKit types. A dereferenced type has had all of its references looked up (and all of its properties' references, all the way down).

Expand Down
6 changes: 6 additions & 0 deletions Sources/OpenAPIKitCore/AnyCodable/AnyCodable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ extension AnyCodable: Encodable {
try container.encode(array.map { AnyCodable($0) })
case let dictionary as [String: Any?]:
try container.encode(dictionary.mapValues { AnyCodable($0) })
case let encodableValue as Encodable:
try container.encode(encodableValue)
default:
let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyCodable value cannot be encoded")
throw EncodingError.invalidValue(value, context)
Expand Down Expand Up @@ -196,6 +198,8 @@ extension AnyCodable: Equatable {
return lhs == rhs
case let (lhs as [String: AnyCodable], rhs as [String: AnyCodable]):
return lhs == rhs
case let (lhs as [String: Any], rhs as [String: Any]):
return lhs.mapValues(AnyCodable.init) == rhs.mapValues(AnyCodable.init)
case let (lhs as [String], rhs as [String]):
return lhs == rhs
case let (lhs as [Int], rhs as [Int]):
Expand All @@ -206,6 +210,8 @@ extension AnyCodable: Equatable {
return lhs == rhs
case let (lhs as [AnyCodable], rhs as [AnyCodable]):
return lhs == rhs
case let (lhs as [Any], rhs as [Any]):
return lhs.map(AnyCodable.init) == rhs.map(AnyCodable.init)
default:
return false
}
Expand Down
75 changes: 75 additions & 0 deletions Tests/AnyCodableTests/AnyCodableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,85 @@ class AnyCodableTests: XCTestCase {
XCTAssertNotEqual(AnyCodable(()), AnyCodable(true))
}

func testEqualityFromJSON() throws {
let json = """
{
"boolean": true,
"integer": 1,
"string": "string",
"array": [1, 2, 3],
"nested": {
"a": "alpha",
"b": "bravo",
"c": "charlie"
}
}
""".data(using: .utf8)!
let decoder = JSONDecoder()
let anyCodable0 = try decoder.decode(AnyCodable.self, from: json)
let anyCodable1 = try decoder.decode(AnyCodable.self, from: json)
XCTAssertEqual(anyCodable0, anyCodable1)
}

struct CustomEncodable: Encodable {
let value1: String

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode("hi hi hi " + value1)
}
}

func test_encodable() throws {
let value = CustomEncodable(value1: "hello")
let anyCodable = AnyCodable(value)
let thing = try JSONEncoder().encode(anyCodable)
XCTAssertEqual(String(data: thing, encoding: .utf8)!, "\"hi hi hi hello\"")
}

func testVoidDescription() {
XCTAssertEqual(String(describing: AnyCodable(Void())), "nil")
}

func test_encodedDecodedURL() throws {
let value = URL(string: "https://www.google.com")
let anyCodable = AnyCodable(value)

// URL's absoluteString compares as equal to the wrapped any codable description.
XCTAssertEqual(value?.absoluteString, anyCodable.description)

let encodedValue = try JSONEncoder().encode(value)
let encodedAnyCodable = try JSONEncoder().encode(anyCodable)
// the URL and the wrapped any codable encode as equals.
XCTAssertEqual(encodedValue, encodedAnyCodable)

let decodedFromValue = try JSONDecoder().decode(AnyCodable.self, from: encodedValue)
// the URL decoded as any codable has the same description as the original any codable wrapper.
XCTAssertEqual(anyCodable.description, decodedFromValue.description)

let decodedFromAnyCodable = try JSONDecoder().decode(AnyCodable.self, from: encodedAnyCodable)
// the decoded any codable has the same description as the original any codable wrapper.
XCTAssertEqual(anyCodable.description, decodedFromAnyCodable.description)

func roundTripEqual<A: Codable, B: Codable>(_ a: A, _ b: B) throws -> Bool {
let a = try JSONDecoder().decode(AnyCodable.self,
from: JSONEncoder().encode(a))
let b = try JSONDecoder().decode(AnyCodable.self,
from: JSONEncoder().encode(b))
return a == b
}
// if you encode/decode both, the URL and its AnyCodable wrapper are equal.
try XCTAssert(roundTripEqual(anyCodable, value))

func encodedEqual<A: Codable, B: Codable>(_ a: A, _ b: B) throws -> Bool {
let a = try JSONEncoder().encode(a)
let b = try JSONEncoder().encode(b)
return a == b
}
// if you just compare the encoded data, the URL and its AnyCodable wrapper are equal.
try XCTAssert(encodedEqual(anyCodable, value))
}

func testJSONDecoding() throws {
let json = """
{
Expand Down

0 comments on commit 0629b7e

Please sign in to comment.