Linh Tạ

Decode dynamic key

Không ít lần mình gặp trường hợp cùng một API nhưng key trả về lại khác nhau. Có rất nhiều cách để giải quyết bài toán này như custom hàm init(from decoder:) hay cầu xin đội backend sửa API. Tuy nhiên vấn đề chung của cả hai cách trên là chúng khá thủ công và tốn sức. Ở bài viết này, cùng mình sử dụng JSONDecoder để tìm ra một giải pháp generic và reusable.

JSON với dynamic key

Hãy tưởng tượng chúng ta đang làm ứng dụng banking và đây là response từ server. Dynamic key ở trường hợp này là amount với các biến thể như weirdAmount, specialAmount, hay totalAmount


let clientJSON = """
{
    "clients": [
        {
            "name": "John",
            "amount": 1000
        },
        {
            "name": "Tony",
            "weirdAmount": 2000
        },
        {
            "name": "Alex",
            "specialAmount": 3000
        },
        {
            "name": "Paul",
            "totalAmount": 4000
        }
    ]
}
""".data(using: .utf8)!

Dựa theo response trên, ta tạo ra ClientList chứa danh sách các Client và conform chúng với protocol Decodable. Client sẽ nhóm chung tất cả các phiên bản của amount vào biến amount


struct ClientList: Decodable {
    let clients: [Client]
}

struct Client: Decodable {
    let name: String
    let amount: Double
    
    enum CodingKeys: String, CodingKey {
        case name
        case amount
    }
}


keyDecodingStrategy

Trong trường hợp lý tưởng, ta muốn JSONDecoder tự động decode bằng cách gọi hàm


try decoder.decode(ClientList.self, from: clientJSON)

Để đạt được điều này, ta có thể cung cấp custom decoding strategy cho thuộc tính keyDecodingStrategy của JSONDecoder. Ngoại trừ .useDefaultKeys, nếu bạn cung cấp một giá trị khác cho keyDecodingStrategy, JSONDecoder sẽ chạy strategy đó với từng key trong JSON để decode chúng


let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .custom(//([CodingKey]) -> CodingKey)

JSONDecoder.KeyDecodingStrategy.custom nhận vào một closure ở dạng ([CodingKey]) -> CodingKey. Đây chính là closure được chạy với mọi JSON key tại thời điểm decode. Input array [CodingKey] chứa toàn bộ các key cha dẫn đến key hiện giờ để tiện cho việc decode. Khi làm việc với array này, key mà chúng ta quan tâm luôn nằm ở vị trí cuối cùng.

DynamicKey

Key .amount trong enum CodingKeys của Client là key tĩnh với rawValueString "amount". Nếu giữ nguyên như hiện trạng, ta sẽ gặp lỗi không tìm được key khi decode các JSON object chứa weirdAmount, specialAmount, hay totalAmount bởi rawValue của chúng không khớp "amount".

Chính vì lý do này, ta cần tạo ra một struct CodingKey đặc biệt có nhiệm vụ thay thế cho key .amount


struct DynamicKey: CodingKey {
    var stringValue: String
    init?(stringValue: String) { self.stringValue = stringValue }
    
    var intValue: Int?
    init?(intValue: Int) {
        self.stringValue = "\(intValue)"
        self.intValue = intValue
    }
    
    init(string: String) { self.stringValue = string }
}

Giờ đây, mỗi khi bắt gặp JSON key có rawValue khác với "amount", ta chỉ cần khởi tạo DynamicKey với rawValue mong muốn và swap chúng một cách dễ dàng


//inputs chứa các phiên bản động mà bạn muốn transform thành cùng 1 output
func convert(from inputs: Set<String>, to output: String) -> ([CodingKey]) -> CodingKey {
    return { keys in
        //trả ra rawValue rỗng nếu key array empty
        guard let last = keys.last else { return DynamicKey(string: "") }

        //nếu key hiện giờ có trong inputs thì swap nó với rawValue output
        return inputs.contains(last.stringValue)
                ? DynamicKey(string: output) : last
    }
}

let decoder = JSONDecoder()
let closure = convert(from: ["weirdAmount", "specialAmount", "totalAmount"],
                      to: Client.CodingKeys.amount.rawValue)
decoder.keyDecodingStrategy = .custom(closure)

try decoder.decode(ClientList.self)


Hoàn thiện function “convert”

DynamicKey và function convert là toàn bộ những gì ta cần để giải quyết bài toán. Tuy nhiên cách sử dụng hiện giờ khá thủ công và dễ xảy ra lỗi khi ta phải cung cấp input đầu vào và ouput đầu ra ở tất cả những nơi decode. Không chỉ vậy, cách làm trên còn gò bó và khó reuse bởi không gì ngăn chúng ta transform rawValue đầu vào thành một dạng CodingKey khác thay vì DynamicKey.

Nếu quan sát kỹ, ta có thể khái quát việc biến đổi rawValue thành dạng phù hợp được cấu thành từ 2 bước:

  1. Check điều kiện input đầu vào
  2. Biến đổi input thành output nếu thoả mãn điều kiện

Phiên bản generic của function convert sẽ trông như dưới đây


func convert(transformer: @escaping (CodingKey?) -> CodingKey,
             when predicate: @escaping (CodingKey) -> Bool) -> ([CodingKey]) -> CodingKey {
    return { keys in
        guard let last = keys.last else { return transformer(nil) }
        return predicate(last) ? transformer(last) : last
    }
}

Giờ đây dựa vào từng strategy cụ thể mà ta có thể tạo ra phiên bản convert phù hợp nhất thay vì một function thiếu linh hoạt như ban đầu


func convert(from inputs: Set<String>, to output: String) -> ([CodingKey]) -> CodingKey {
    return convert(transformer: { _ in DynamicKey(string: output) },
                   when: { inputs.contains($0.stringValue) })
}

Quay lại trường hợp dynamic key .amount, để loại bỏ hoàn toàn việc cung cấp input - output thủ công, ta sẽ thực hiện một chỉnh sửa cuối cùng


protocol SpecialCaseConvertible: CaseIterable, RawRepresentable
                                                where RawValue == String {
    static var targetCase: String { get }
}

struct Clients: Decodable {
    let clients: [Client]
}

struct Client: Decodable {
    let name: String
    let amount: Double
    
    enum CodingKeys: String, CodingKey {
        case name
        case amount
    }
    
    enum SpecialCase: String, SpecialCaseConvertible {
        case totalAmount
        case weirdAmount
        case specialAmount

        static var targetCase: String { CodingKeys.amount.rawValue }
    }
}

func convert<T: SpecialCaseConvertible>(_ type: T.Type) -> ([CodingKey]) -> CodingKey {
    let inputs = Set(T.allCases.map { $0.rawValue })
    return convert(transformer: { _ in DynamicKey(string: T.targetCase) },
                   when: { inputs.contains($0.stringValue) })
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .custom(convert(Client.SpecialCase.self))

try decoder.decode(Clients.self, from: clientJSON)

Với protocol SpecialCaseConvertible, tất cả các type conform nó sẽ mặc định decode ra targetCase mà không còn phải hardcode. Cách làm này giúp code clear và an toàn hơn khi tất cả những gì bạn cần biết đều tập trung ở model layer là enum SpecialCase và thay đổi của nó không ảnh hưởng tới những nơi nó được decode.

Tổng kết

Trên đây là hướng tiếp cận của mình với bài toán decode dynamic key. Mong rằng nó sẽ có ích với bạn và hẹn gặp lại ở những bài viết tới.

Source code: Decode Dynamic Key