Reputation: 7409
I have the following JSON object:
{
"user_name":"Mark",
"user_info":{
"b_a1234":"value_1",
"c_d5678":"value_2"
}
}
I've set up my JSONDecoder
like so:
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
And my Decodable
object looks like this:
struct User: Decodable {
let userName: String
let userInfo: [String : String]
}
The problem I'm facing is the .convertFromSnakeCase
strategy is being applied to the dictionary's keys, and I'd like for that to not happen.
// Expected Decoded userInfo
{
"b_a1234":"value_1",
"c_d5678":"value_2"
}
// Actual Decoded userInfo
{
"bA1234":"value_1",
"cD5678":"value_2"
}
I've looked into using a custom keyDecodingStrategy
(but there's not enough information to handle dictionaries differently), as well as a custom initializer for my Decodable
struct (seems like the keys have already been converted by this point).
What is the proper way of handling this (creating an exception for key conversion only for dictionaries)?
Note: I would prefer to keep the snake case conversion strategy since my actual JSON objects have a lot of properties in snake case. My current workaround is to use a CodingKeys enum to manually do the snake case conversion.
Upvotes: 2
Views: 2788
Reputation: 31
Alternatively you could use CodingKeys, that way you have more control and can specify name for each field. Then you don't have to set keyDecodingStrategy
struct User: Decodable {
let userName: String
let userInfo: [String : String]
enum CodingKeys: String, CodingKey {
case userName = "user_name"
case userInfo = "user_info"
}
}
Upvotes: 1
Reputation: 299275
Yes...but, it's a bit tricky, and in the end it may be more robust just to add CodingKeys. But it is possible, and a decent introduction to custom key decoding strategies.
First, we need a function to do the snake-case conversion. I really really wish this were exposed in stdlib, but it isn't, and I don't know any way to "get there" without just copying the code. So here's the code based directly on JSONEncoder.swift. (I hate to even copy this into the answer, but otherwise you won't be able to reproduce the rest.)
// Makes me sad, but it's private to JSONEncoder.swift
// https://github.com/apple/swift/blob/master/stdlib/public/Darwin/Foundation/JSONEncoder.swift
func convertFromSnakeCase(_ stringKey: String) -> String {
guard !stringKey.isEmpty else { return stringKey }
// Find the first non-underscore character
guard let firstNonUnderscore = stringKey.firstIndex(where: { $0 != "_" }) else {
// Reached the end without finding an _
return stringKey
}
// Find the last non-underscore character
var lastNonUnderscore = stringKey.index(before: stringKey.endIndex)
while lastNonUnderscore > firstNonUnderscore && stringKey[lastNonUnderscore] == "_" {
stringKey.formIndex(before: &lastNonUnderscore)
}
let keyRange = firstNonUnderscore...lastNonUnderscore
let leadingUnderscoreRange = stringKey.startIndex..<firstNonUnderscore
let trailingUnderscoreRange = stringKey.index(after: lastNonUnderscore)..<stringKey.endIndex
var components = stringKey[keyRange].split(separator: "_")
let joinedString : String
if components.count == 1 {
// No underscores in key, leave the word as is - maybe already camel cased
joinedString = String(stringKey[keyRange])
} else {
joinedString = ([components[0].lowercased()] + components[1...].map { $0.capitalized }).joined()
}
// Do a cheap isEmpty check before creating and appending potentially empty strings
let result : String
if (leadingUnderscoreRange.isEmpty && trailingUnderscoreRange.isEmpty) {
result = joinedString
} else if (!leadingUnderscoreRange.isEmpty && !trailingUnderscoreRange.isEmpty) {
// Both leading and trailing underscores
result = String(stringKey[leadingUnderscoreRange]) + joinedString + String(stringKey[trailingUnderscoreRange])
} else if (!leadingUnderscoreRange.isEmpty) {
// Just leading
result = String(stringKey[leadingUnderscoreRange]) + joinedString
} else {
// Just trailing
result = joinedString + String(stringKey[trailingUnderscoreRange])
}
return result
}
We also want a little CodingKey Swiss-Army knife that should also be in the stdlib, but isn't:
struct AnyKey: CodingKey {
var stringValue: String
var intValue: Int?
init?(stringValue: String) {
self.stringValue = stringValue
self.intValue = nil
}
init?(intValue: Int) {
self.stringValue = String(intValue)
self.intValue = intValue
}
}
This just lets you turn any string into a CodingKey. It comes out of the JSONDecoder docs.
Finally, that's all the boilerplate junk. Now we can get to the heart of it. There's no way to say "except for in Dictionaries" directly. CodingKeys are interpreted independently of any actual Decodable. So what you want is a function that says "apply snake case unless this is a key nested inside of such-and-such key." Here's a function that returns that function:
func convertFromSnakeCase(exceptWithin: [String]) -> ([CodingKey]) -> CodingKey {
return { keys in
let lastKey = keys.last!
let parents = keys.dropLast().compactMap {$0.stringValue}
if parents.contains(where: { exceptWithin.contains($0) }) {
return lastKey
}
else {
return AnyKey(stringValue: convertFromSnakeCase(lastKey.stringValue))!
}
}
}
With that, we just need a custom key decoding strategy (notice that this uses the camel-case version of "userInfo" because the CodingKey path is after conversions are applied):
decoder.keyDecodingStrategy = .custom(convertFromSnakeCase(exceptWithin: ["userInfo"]))
And the result:
User(userName: "Mark", userInfo: ["b_a1234": "value_1", "c_d5678": "value_2"])
I can't promise this is worth the trouble vs just adding CodingKeys, but it's a useful tool for the toolbox.
Upvotes: 6