Ran into an Encodable situation that puzzled me for a bit today.
For those who don’t want to read much, the key observation is that try foo.encode(to: container.superEncoder(forKey: key))
will DELETE any existing key
node in the container and the encode foo into a new node with that key. However, nestedContainer(keyedBy: CodingKeys.self, forKey: key)
will add encoded items to an existing key
if it already exists.
Here’s the blow-by-blow of me figuring that out 🙂
(I have a lot of Swift yet to learn, so it’s likely there is a better way to do all this, but my searching wasn’t finding anything and this works and isn’t entirely horrible, so I thought I’d post it in case it helps someone else).
I have the following model which is already Codable
(only Encodable
shown for brevity):
public enum UserIdentifier: Encodable {
case email(String)
case phone(String)
case guestID(String)
}
extension UserIdentifier {
enum CodingKeys: String, CodingKey {
case address, phone, gid
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case let .email(value):
try container.encode(value, forKey: CodingKeys.address)
case let .phone(value):
try container.encode(value, forKey: CodingKeys.phone)
case let .guestID(value):
try container.encode(value, forKey: CodingKeys.gid)
}
}
}
This then needed to be a field in a struct representing the body of a POST request to an http server:
struct PostBody: Encodable {
let userID: User.UserIdentifier
let name: String
let password: String
}
and what the server expected in the body is JSON of the form:
{
"address" : "bob@email.com",
"username" : "Robert Tree",
"password" : "MyBadPassword!"
}
To see what the output of the default Encodable
support will output I created a little test case (I often do this kind of thing in a playground, but this was already embedded in an iOS app so it was easiest to just make a test case):
class Tests: XCTestCase {
func testCreateUserPostBodyToJSON() throws {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let body = UserService.CreateUserPostBody(userID: User.UserIdentifier.email("bob@email.com"),
name: "Robert Tree",
password: "MyBadPassword!")
let jsonData = try encoder.encode(body)
print(String(data: jsonData, encoding: .utf8)!)
}
}
and, of course we expect this won’t be right, but it’s our starting point. Here’s what it outputs:
{
"userID" : {
"address" : "bob@email.com"
},
"name" : "Robert Tree",
"password" : "MyBadPassword!"
}
So clearly we need a custom encode
function to pull the userID enum contents up to the top level. Let’s try this:
func encode(to encoder: Encoder) throws {
enum CodingKeys: String, CodingKey {
case username, password
}
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .username)
try container.encode(password, forKey: .password)
try userID.encode(to: encoder)
}
The key is that last line: try userID.encode(to: encoder)
. Here’s the new output:
{
"address" : "bob@email.com",
"username" : "Robert Tree",
"password" : "MyBadPassword!"
}
Bingo! Just what the doctor ordered!
Except then I try the request to the server and it turns out the api documents for the server are out of date (🤦🏼♂️) and what the server really wants is:
{
"user" : {
"address" : "bob@email.com",
"username" : "Robert Tree",
"password" : "MyBadPassword!"
}
}
Hmmm. Ok, so this is a little more challenging, but it’s what nestedContainer
is for, so we’ll try using that:
func encode(to encoder: Encoder) throws {
enum ParentCodingKeys: String, CodingKey {
case user
}
enum CodingKeys: String, CodingKey {
case username, password
}
var container = encoder.container(keyedBy: ParentCodingKeys.self)
var nestedContainer = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .user)
try nestedContainer.encode(name, forKey: .username)
try nestedContainer.encode(password, forKey: .password)
try userID.encode(to: container.superEncoder(forKey: .user))
}
Looks fancy. Let’s try it:
{
"user" : {
"address" : "bob@email.com"
}
}
OH NO!!! what happened?!!
Hmmm… That looks like just the output of the last line, so comment out
try userID.encode(to: container.superEncoder(forKey: .user))
and see if the first part is working at least:
{
"user" : {
"username" : "Robert Tree",
"password" : "MyBadPassword!"
}
}
Ok. So the first part is creating things exactly how we want it to, it’s just that last line isn’t adding itself to the already existing "user" : { }
node. Rats! Documentation isn’t shedding any light (it *does* in fact shed light, but it’s too subtle for me to catch on a too-quick read; as we’ll see below). Tried this on a whim:
func encode(to encoder: Encoder) throws {
enum ParentCodingKeys: String, CodingKey {
case user
}
enum CodingKeys: String, CodingKey {
case username, password
}
var container = encoder.container(keyedBy: ParentCodingKeys.self)
try userID.encode(to: container.superEncoder(forKey: .user))
var nestedContainer = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .user)
try nestedContainer.encode(name, forKey: .username)
try nestedContainer.encode(password, forKey: .password)
}
And the output is:
{
"user" : {
"address" : "bob@email.com",
"username" : "Robert Tree",
"password" : "MyBadPassword!"
}
}
Success!
So, apparently, superEncoder(forKey: key)
(Docs, emphasis mine: “Stores a new nested container for the given key and returns A new encoder instance for encoding super into that container.”) always makes the node for key
and overwrites whatever happened to be there before (how rude!). Luckily, nestedContainer(keyedBy: Keys, forKey: key)
(Docs: “Stores a keyed encoding container for the given key and returns it.”) doesn’t do that and will just add items to the node if it already exists. Sweet!
Follow up:
It occurs to me that the solution above only works because there’s only a single enum like UserIdentifier
to include in the nested structure. They can’t both be first and so the second one would overwrite the first one.
Hopefully there’s another better way to do this that someone will point out in a comment which can handle multiple objects.