0

I have a requirement to encode/decode snakeCased JSONs. I found that encoder encodes Value2 object correctly, however decoder fails to decode it. What I do wrong here?

Required Json format:

{
  "address_line_1" : "Address",
  "full_name" : "Name",
  "id" : 2
}

Code:

struct Value1: Codable {
    let id: Int
    let fullName: String
    let addressLine1: String
}

struct Value2: Codable {
    let id: Int
    let fullName: String
    let addressLine_1: String
}

func printJson(_ object: Data) throws {
    let json = try JSONSerialization.jsonObject(with: object, options: [])
    let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
    print(String(data: data, encoding: .utf8)!)
}

func encode<T: Encodable>(_ object: T) throws -> Data {
    let encoder = JSONEncoder()
    encoder.keyEncodingStrategy = .convertToSnakeCase
    return try encoder.encode(object)
}

func decode<T: Decodable>(_ type: T.Type, from data: Data) throws {
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    _ = try decoder.decode(type, from: data)
    print("✅ Decoded \(type) from:")
    try printJson(data)
}

do {
    var data: Data

    data = try encode(Value1(id: 1, fullName: "Name", addressLine1: "Address"))
    try decode(Value1.self, from: data)
    

    data = try encode(Value2(id: 2, fullName: "Name", addressLine_1: "Address"))
    _ = try decode(Value1.self, from: data)
    _ = try decode(Value2.self, from: data)
    
} catch {
    print("❌ Failed with error:", error)
}

Output:

✅ Decoded Value1 from:
{
  "address_line1" : "Address",
  "full_name" : "Name",
  "id" : 1
}
✅ Decoded Value1 from:
{
  "address_line_1" : "Address",
  "full_name" : "Name",
  "id" : 2
}
❌ Failed with error: keyNotFound(CodingKeys(stringValue: "addressLine_1", intValue: nil), Swift.DecodingError.Context(codingPath: [], debugDescription: "No value associated with key CodingKeys(stringValue: \"addressLine_1\", intValue: nil) (\"addressLine_1\"), with divergent representation addressLine1, converted to address_line_1.", underlyingError: nil))
1
  • 2
    Because you are using addressLine_1, so it expects addressLine_1 , but since your encoder keystrategy is snakeCase, it changes it. Don't mix it like that, either use only camelCase for var, and use snake strategy, or use your own CodingKeys. Commented Mar 16, 2021 at 13:14

2 Answers 2

1

convertFromSnakeCase works correctly and you can can check it in first decode:

_ = try decode(Value1.self, from: data)

After that, when you try to decode the same data but with Value2 type it surely fails as it expects different property name. This is your encoded snake case JSON:

{
  "address_line_1" : "Address",
  "full_name" : "Name",
  "id" : 2
}

After decoder conversion address_line_1 becomes addressLine1 (the same applies to full_name) which fits properties of Value1. If you try to decode the same data for Value2 it fails as property name requires addressLine_1.

In your case, optimal strategy would be to use custom coding keys, like this:

struct Value2: Codable {
    private enum Value2CodingKey: String, CodingKey {
        case id
        case fullName = "full_name"
        case addressLine1 = "address_line_1"
    }

    let id: Int
    let fullName: String
    let addressLine1: String
}
Sign up to request clarification or add additional context in comments.

Comments

0

I found a solution without using custom coding keys, but custom coding strategy instead, so coders handle _ before numbers as well. So that addressLine1 encodes to address_line_1, and address_line_1 decodes to addressLine1

Usage:

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCaseWithNumbers

let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCaseWithNumbers

Coder implementation:

extension JSONEncoder.KeyEncodingStrategy {
    static var convertToSnakeCaseWithNumbers: JSONEncoder.KeyEncodingStrategy {
        .custom { codingKeys -> CodingKey in
            let stringValue = codingKeys.last!.stringValue
            let newKey = AnyKey(stringValue: convertToSnakeCase(stringValue))!
            return newKey
        }
    }
    
    private static func convertToSnakeCase(_ stringKey: String) -> String {
        var key = stringKey
        let searchRange = key.index(after: key.startIndex)..<key.endIndex
        let nsRange = key.nsRange(from: searchRange)
        let matches = NSRegularExpression("([A-Z])|([0-9]+)").matches(in: key, options: [], range: nsRange)
        for match in matches.reversed() {
            guard let range = key.range(from: match.range) else { continue }
            key.insert("_", at: range.lowerBound)
        }
        return key.lowercased()
    }
}

extension JSONDecoder.KeyDecodingStrategy {
    static var convertFromSnakeCaseWithNumbers: JSONDecoder.KeyDecodingStrategy {
        .custom { (codingKeys) -> CodingKey in
            let stringValue = codingKeys.last!.stringValue
            let newKey = AnyKey(stringValue: convertFromSnakeCase(stringValue))!
            return newKey
        }
    }
    
    private static func convertFromSnakeCase(_ stringKey: String) -> String {
        guard stringKey.contains("_") else {
            return stringKey
        }
        let components = stringKey.split(separator: "_").map({ $0.firstCapitalized })
        return components.joined().firstLowercased
    }
}

private extension NSRegularExpression {
    convenience init(_ pattern: String) {
        do {
            try self.init(pattern: pattern)
        } catch {
            preconditionFailure("Illegal regular expression: \(pattern).")
        }
    }
}

private extension StringProtocol {
    var firstLowercased: String { prefix(1).lowercased() + dropFirst() }
    var firstCapitalized: String { prefix(1).capitalized + dropFirst() }
}

enum AnyKey: CodingKey {
    case string(String)
    case int(Int)
    
    var stringValue: String {
        switch self {
        case .string(let string):
            return string
        case .int(let int):
            return "\(int)"
        }
    }
    
    var intValue: Int? {
        guard case let .int(int) = self else { return nil }
        return int
    }
    
    init?(stringValue: String) {
        guard !stringValue.isEmpty else { return nil }
        self = .string(stringValue)
    }
    
    init?(intValue: Int) {
        self = .int(intValue)
    }
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.