イケてない JSON を Swift の Decodable で扱いやすいモデルにデコードする

last.fm というサービスがあって、API も公開されているのでそれを Swift で使っているのだが、JSON の構造がイケてなくて苦労している。

こんな感じの JSON があったとする。

{
    "user": {
        "name": "RJ",
        "age": "20",
        "image": [
           {
               "#text": "https://lastfm-img2.akamaized.net/i/u/34s/aaa.png",
               "size": "small"
           },
           {
               "#text": "https://lastfm-img2.akamaized.net/i/u/64s/bbb.png",
               "size": "medium"
           }
       ],
        "url": "https://www.last.fm/user/RJ",
        "registered": {
            "#text": 1037793040,
            "unixtime": "1037793040"
        }
    }
}

image はどんな user でも small, medium しかないので array である必要はないし、age は Int だし、 registered も Date 型で扱いたい。あとトップレベルが user だけど、 user を消して階層も1段上にあげたい。

つまりこんな感じのモデルにマッピングしたい。

public struct Image: Decodable {
    public let small: URL?
    public let medium: URL?
}

public struct User: Decodable {
    public let name: String
    public let age: Int
    public let url: URL
    public let registered: Date
    public let image: Image
}

ネストした構造を 1 段上に上げる

user.name の構造だけど、これを name として decode したい。
一旦 user を decode して、さらに name を decode すれば良さそう。

public struct User: Decodable {
    public let name: String
    ....

    private enum CodingKeys: String, CodingKey {
        case name
        ....
    }

    private enum UserKeys: String, CodingKey {
        case user
    }

    public init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: UserKeys.self)

        let user = try values.nestedContainer(keyedBy: CodingKeys.self, forKey: .user)
        name = try user.decode(String.self, forKey: .name)
        ....
    }
}

String を Int にする

Swift 4 JSON Decodable simplest way to decode type change - Stack Overflow を参考にした。

  • LosslessStringConvertible
    • 100 と "100", 10.0 と "10.0" みたいに String と数値をいい感じに変換できる時に使える
  • decoder.singleValueContainer()
    • 1つしか値を持っていない場合限定で使う
struct StringCodableMap<Decoded: LosslessStringConvertible>: Decodable {
    var decoded: Decoded

    init(_ decoded: Decoded) {
        self.decoded = decoded
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let decodedString = try container.decode(String.self)

        guard let decoded = Decoded(decodedString) else {
            throw DecodingError.dataCorruptedError(
                in: container, debugDescription: """
                The string \(decodedString) is not representable as a \(Decoded.self)
                """
            )
        }

        self.decoded = decoded
    }
}

...

    public init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: UserKeys.self)

        let user = try values.nestedContainer(keyedBy: CodingKeys.self, forKey: .user)
        name = try user.decode(String.self, forKey: .name)
        age = try user.decode(StringCodableMap<Int>.self, forKey: .age).decoded
    }

ググるInt(try values.decode(String.self, forKey: .age)) ?? 0 などが出て確かに手軽だけど、Optional を 0 で握りつぶしたくないし使いまわせる形にしたかったので StringCodableMap を定義する方法にした。

registered オブジェクトを Date 型にする

       "registered": {
            "#text": 1037793040,
            "unixtime": "1037793040"
        }

これを registered: Date で扱えるようにする。

StringCodableMap のようにカスタム Decodable を定義した。やっていることは今までの応用。
いったん registered を decode して、 #text を取り出して Int に変換して、それを Date にしている。

struct RegisteredDecodableMap: Decodable {
    var decoded: Date

    enum Registered: String, CodingKey {
        case text = "#text"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Registered.self)
        let time = try container.decode(Double.self, forKey: .text)
        let decoded = Date(timeIntervalSince1970: time)
        self.decoded = decoded
    }
}

...

    public init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: UserKeys.self)

        let user = try values.nestedContainer(keyedBy: CodingKeys.self, forKey: .user)
        name = try user.decode(String.self, forKey: .name)
        age = try user.decode(StringCodableMap<Int>.self, forKey: .age).decoded
        registered = try user.decode(RegisteredDecodableMap.self, forKey: .registered).decoded
    }

Array の image を普通のパラメータにする

       "image": [
            {
                "#text": "https://lastfm-img2.akamaized.net/i/u/34s/aaa.png",
                "size": "small"
            },
            {
                "#text": "https://lastfm-img2.akamaized.net/i/u/64s/bbb.png",
                "size": "medium"
            }
        ],

これを image.small, image.medium でアクセスできるようにする。

これもカスタム Decodable を作る。

  • try decoder.unkeyedContainer()
    • key のないもの、例えば Array などに使う (Dictionary も?)
  • while !unkeyedContainer.isAtEnd
    • Array なのでループで回す。ループの中で一旦 Decode する
    • それを配列で保存しておいて、あとで small, medium にマッピングする
struct ImageDecodableMap: Decodable {
    var decoded: Image

    enum Size: String, Codable {
        case small
        case medium
    }

    struct _Image: Decodable {
        let text: String
        let size: Size

        enum CodingKeys: String, CodingKey {
            case text = "#text"
            case size
        }
    }

    init(from decoder: Decoder) throws {
        var images: [_Image] = []
        var unkeyedContainer = try decoder.unkeyedContainer()
        while !unkeyedContainer.isAtEnd {
            let image = try unkeyedContainer.decode(_Image.self)
            images.append(image)
        }
        var small: URL?
        var medium: URL?
        images.forEach { img in
            let url = URL(string: img.text)
            switch img.size {
            case .small:
                small = url
            case .medium:
                medium = url
            }
        }

        self.decoded = Entity.Image(small: small, medium: medium)
    }
}

...

    public init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: UserKeys.self)

        let user = try values.nestedContainer(keyedBy: CodingKeys.self, forKey: .user)
        name = try user.decode(String.self, forKey: .name)
        age = try user.decode(StringCodableMap<Int>.self, forKey: .age).decoded
        registered = try user.decode(RegisteredDecodableMap.self, forKey: .registered).decoded
        image = try user.decode(ImageDecodableMap.self, forKey: .image).decoded
    }

おわり

最終的にこんな感じになった。 LastfmClient/Entity.swift at master · starhoshi/LastfmClient · GitHub

複雑な JSON を平坦にしたいだけだったのだが、結構大変だった。
extension KeyedDecodingContainer する方法もあるようだし、どっちがより良いのかはわからないけど、とりあえず汎用的に使いまわせるようにできたので悪くない気がしている。

参考