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

イケてない JSON を Swift の Decodable で扱いやすいモデルにデコードする の続き。
今度も last.fmuser.getRecentTracks を Decode していく。

尚、文字を打つのが面倒になったためこの記事はあらゆる説明を省略している。

JSON

これを decode していく。

{
    "recenttracks": {
        "track": [
            {
                "artist": {
                    "#text": "TOKIO",
                    "mbid": "d210bd3e-68db-4987-a714-0214449e361d"
                },
                "name": "宙船",
                "streamable": "0",
                "mbid": "",
                "album": {
                    "#text": "Harvest",
                    "mbid": "3824d059-6f1a-4163-ae1a-65d5f87bee38"
                },
                "url": "https://www.last.fm/music/TOKIO/_/%E5%AE%99%E8%88%B9",
                "image": [
                    {
                        "#text": "https://lastfm-img2.akamaized.net/i/u/34s/1a20e578c4ea44c0a8cde03d6c6cc014.png",
                        "size": "small"
                    },
                    {
                        "#text": "https://lastfm-img2.akamaized.net/i/u/64s/1a20e578c4ea44c0a8cde03d6c6cc014.png",
                        "size": "medium"
                    }
                ],
                "@attr": {
                    "nowplaying": "true"
                }
            },
            ...............
        ],
        "@attr": {
            "user": "star__hoshi",
            "page": "1",
            "perPage": "50",
            "totalPages": "807",
            "total": "40316"
        }
    }
}

List 系 API の共通要素

last.fm の List 系 API は、データ本体の配列と attr という API メタデータの 2 つから構成されていて他の API でも同じなので ListResponse という protocol を用意した。
この List に RecentTrack や TopTrack を入れると Generics でいい感じになる。

public protocol ListResponse {
    associatedtype List

    var list: [List] { get }
    var attr: Attr { get }
}

public struct Attr: Decodable {
    public let user: String
    public let page: Int
    public let perPage: Int
    public let totalPages: Int
    public let total: Int
}

protocol を実装するとこんな感じになる。

    public struct RecentTracksResponse: ListResponse, Decodable {
        public typealias List = RecentTrack
        public let list: [List]
        public let attr: Attr

        private enum CodingKeys: String, CodingKey {
            case list
            case attr
        }

        private enum RecentTracksKeys: String, CodingKey {
            case recenttracks
        }

        private enum TrackAttrKeys: String, CodingKey {
            case track
            case attr = "@attr"
        }

        public init(from decoder: Decoder) throws {
            let values = try decoder.container(keyedBy: RecentTracksKeys.self)
            let recentTracks = try values.nestedContainer(keyedBy: TrackAttrKeys.self, forKey: .recenttracks)
            self.attr = try recentTracks.decode(Attr.self, forKey: .attr)
            self.list = try recentTracks.decode([List].self, forKey: .track)
        }
    }

配列の中身を decode

nowplaying がネストしててもしょうもないので、 @attr で decode してそこからさらに nowplaying で decode するようにした。
last.fmAPI は本当にひどいので、 nowplaying が false 場合はそもそも @attr が空っぽになってる。なので if decoder.contains(.nowplaying) してから decode している。

Image とか String -> Int の変換とかは こっち で先にやっていたので使い回せて便利。

public struct RecentTrack: Decodable {
    public let name: String
    public let image: Image
    public let loved: Bool?
    public let streamable: Bool
    public let mbid: String
    public let url: URL
    public let date: Date?
    public let nowplaying: Bool
    public let album: Album
    public let artist: RecentArtist

    private enum CodingKeys: String, CodingKey {
        case name
        case image
        case loved
        case streamable
        case mbid
        case url
        case date
        case album
        case artist
        case nowplaying = "@attr"
    }

    private enum NowplayingKeys: String, CodingKey {
        case nowplaying
    }

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

        name = try decoder.decode(String.self, forKey: .name)
        image = try decoder.decode(ImageDecodableMap.self, forKey: .image).decoded
        let loved = try decoder.decodeIfPresent(StringCodableMap<Int>.self, forKey: .loved)?.decoded
        self.loved = loved == nil ? nil : loved == 1
        streamable = try decoder.decode(StringCodableMap<Int>.self, forKey: .streamable).decoded == 1
        mbid = try decoder.decode(String.self, forKey: .mbid)
        url = try decoder.decode(URL.self, forKey: .url)
        date = try decoder.decodeIfPresent(DateDecodableMap.self, forKey: .date)?.decoded
        album = try decoder.decode(Album.self, forKey: .album)
        artist = try decoder.decode(RecentArtist.self, forKey: .artist)
        if decoder.contains(.nowplaying) {
            let nowplayingDecoder = try decoder.nestedContainer(keyedBy: NowplayingKeys.self, forKey: .nowplaying)
            nowplaying = try nowplayingDecoder.decodeIfPresent(StringCodableMap<Bool>.self, forKey: .nowplaying)?.decoded ?? false
        } else {
            nowplaying = false
        }
    }
}

最終的には LastfmClient/RecentTrack.swift な感じになった。

本当はもっとややこしくて、 extended=0API 投げると loved や data が帰ってこなかったりするのだが、それは decodeIfPresent でなんとかなる。

おわり

Codable 良いのだが、汚い API は Decode するだけで無限に時間を消費しててつらい。