Mark
Mark

Reputation: 7409

Decodable keyDecodingStrategy custom handling for dictionaries

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

Answers (2)

Den Schigrov
Den Schigrov

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

Rob Napier
Rob Napier

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

Related Questions