iosswiftencodableapollo-ios

Is it possible to force encode Date as Date type instead of string?


I am trying to mock Apollo Queries using its init. It pretty much is taking in a dictionary to build the object up.

public init(unsafeResultMap: [String: Any]) {
  self.resultMap = unsafeResultMap
}

So, I have decided to create Mock objects that have the same properties of the query objects while being Encodable (So we get the free JSON conversion, which can be represented as a string version dictionary).

For example:

class MockAnimalObject: Encodable {
  let teeth: MockTeethObject

  init(teeth: MockTeethObject) {
    self.teeth = teeth
  }
}

class MockTeethObject: Encodable {
  let numberOfTeeth: Int
  let dateOfTeethCreation: Date

  init (numberOfTeeth: Int, dateOfTeethCreation: Date) {
    self.numberOfTeeth = numberOfTeeth
    self.dateOfTeethCreation = dateOfTeethCreation
  }
}

The problem is, the Apollo conversion checks the types during the result map, which in our case is a string of [String: Encodable].

And this is where the Date encodable becomes a problem.

/// This property is auto-generated and not feasible to be edited

/// Teeth date for these teeth
public var teethCreationDate: Date { 
  get {
    // This is the problem. resultMap["teethCreationDate"] is never going to be a Date object since it is encoded. 
    return resultMap["teethCreationDate"]! as! Date 
  }
  set {
    resultMap.updateValue(newValue, forKey: "teethCreationDate")
  }
}

So, I am wondering if it is possible to override the encoder to manually set the date value as a custom type.

var container = encoder.singleValueContainer()
try container.encode(date) as Date // Something where I force it to be a non-encodable object

Solution

  • JSON has nothing to do with this. JSON is not any kind of dictionary. It's a serialization format. But you don't want a serialization format. You want to convert types to an Apollo ResultMap, which is [String: Any?]. What you want is a "ResultMapEncoder," not a JSONEncoder.

    That's definitely possible. It's just an obnoxious amount of code because Encoder is such a pain to conform to. My first pass is a bit over 600 lines. I could probably strip it down more and it's barely tested, so I don't know if this code works in all (or even most) cases, but it's a start and shows how you would attack this problem.

    The starting point is the source code for JSONEncoder. Like sculpture, you start with a giant block of material, and keep removing everything that doesn't look like what you want. Again, this is very, very lightly tested. It basically does what you describe in your question, and not much else is tested.

    let animal = MockAnimalObject(teeth: MockTeethObject(numberOfTeeth: 10, 
                                  dateOfTeethCreation: .now))
    
    let result = try AnyEncoder().encode(animal)
    print(result)
    
    //["teeth": Optional(["dateOfTeethCreation": Optional(2022-08-12 18:35:27 +0000),
    // "numberOfTeeth": Optional(10)])]
    

    The key changes, and where you'd want to explore further to make this work the way you want, are:

    If you want [String: Any] rather than [String: Any?] (which is what ResultMap is), then you can tweak the types a bit. The only tricky piece is you would need to store something like nil as Any? as Any in order to encode nil (or you could encode NSNull, or you could just not encode it at all if you wanted).

    Note that this actually returns Any, since it can't know that the top level encodes an object. So you'll need to as? cast it to [String: Any?].


    To your question about using Mirror, the good thing about Mirror is that the code is short. The bad thing is that mirror is very slow. So it depends on how important that is. Not everything has the mirror you expect, however. For your purposes, Date has a "struct-like" Mirror, so you have to special-case it. But it's not that hard to write the code. Something like this:

    func resultMap(from object: Any) -> Any {
    
        // First handle special cases that aren't what they seem
        if object is Date || object is Decimal {
            return object
        }
    
        let mirror = Mirror(reflecting: object)
    
        switch mirror.displayStyle {
        case .some(.struct), .some(.class), .some(.dictionary):
            var keyValues: [String: Any] = [:]
            for child in mirror.children {
                if let label = child.label {
                    keyValues[label] = resultMap(from: child.value)
                }
            }
            return keyValues
    
        case .some(.collection):
            var values: [Any] = []
            for child in mirror.children {
                values.append(resultMap(from: child.value))
            }
            return values
    
        default:
            return object
        }
    }
    
    let animal = MockAnimalObject(teeth: MockTeethObject(numberOfTeeth: 10, dateOfTeethCreation: .now))
    let result = resultMap(from: animal)
    print(result)
    // ["teeth": ["dateOfTeethCreation": 2022-08-12 21:08:11 +0000, "numberOfTeeth": 10]]
    

    This time I didn't bother with Any?, but you could probably expand it that way if you needed. You'd need to decide what you'd want to do about enums, tuples, and anything else you'd want to handle specially, but it's pretty flexible. Just slow.