[Swift] JSONで値の型が色々ある場合でもCodableでパースする

[JSONSerialization](https://developer.apple.com/documentation/foundation/jsonserialization)でシコシコJSONをパースしていたのも今は昔。

Swift4に`Codable`が現れて以降、JSONのパースは非常に手軽なものになっています。

また強力な型システムの恩恵を受けながらデータを扱えることは、潜在的なエラーの発生も抑えることができます。

ただ型が強力であるが故、逆に扱いづらくなるケースも存在します。

たとえばこんな感じです。

```json
{
    "data": [
        {
            "id": 1,
            "embed": {
                "type": "video",
                "url": "./xxx.mp4",
                "duration": 120
            }
        },
        {
            "id": 2,
            "embed": {
                "type": "book",
                "url": "./xxx.pdf",
                "page": 300
            }
        }
    ]
}
```

`embed`の下にぶら下がるデータが`video`の場合と`book`の場合があり、それぞれ付随するプロパティーが違っています。

この例で言えば`duration`と`page`をオプショナルにするなどでも良いかもしれないですが、`book`に`summary`や`coverImage`を追加するなど、タイプやプロパティーが増えていくと1つの型で扱うのは厳しくなります。

`JSONSerialization`でセコセコ頑張るのも1つの手ですが、こういったフォーマットのJSONももちろん`Codable`で扱うことができます。

------

上記JSONをパースするコードはこのようになります。

```swift
import Foundation

let json = """
{
    "data": [
        {
            "id": 1,
            "embed": {
                "type": "video",
                "url": "./xxx.mp4",
                "duration": 120
            }
        },
        {
            "id": 2,
            "embed": {
                "type": "book",
                "url": "./xxx.pdf",
                "page": 300
            }
        }
    ]
}
"""

/// ルート
struct Root: Codable {
    let data: [Element]

    /// 要素
    struct Element: Codable {
        let id: Int
        let embed: Embed
    }
}

/// 添付データ
enum Embed: Codable {
    case video(Video)
    case book(Book)

    /// ビデオ
    struct Video: Codable {
        let type: `Type`
        let url: URL
        let duration: Int
    }

    /// 本
    struct Book: Codable {
        let type: `Type`
        let url: URL
        let page: Int
    }

    /// タイプ
    enum `Type`: String, Codable {
        case video = "video"
        case book = "book"
    }

    /// コーディングキー
    private enum CodingKeys: CodingKey {
        case type
    }

    /// デコード
    /// - Parameter decoder: デコーダー
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        let type = try values.decodeIfPresent(`Type`.self, forKey: .type)
        switch type {
        case .video:
            self = .video(try Video(from: decoder))
        case .book:
            self = .book(try Book(from: decoder))
        case .none:
            let error = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unknown type")
            throw DecodingError.typeMismatch(Embed.self, error)
        }
    }

    /// エンコード
    /// - Parameter encoder: エンコーダー
    public func encode(to encoder: Encoder) throws {
        switch self {
        case .video(let video):
            try video.encode(to: encoder)
        case .book(let book):
            try book.encode(to: encoder)
        }
    }
}

// デコード
let decoded = try JSONDecoder().decode(Root.self, from: json.data(using: .utf8)!)
decoded.data.forEach {
    switch $0.embed {
    case .video(let video):
        print(video.duration) // 120
    case .book(let book):
        print(book.page) // 300
    }
}
```

------

`enum`で型の違いを吸収し、連想値で実際のデータを渡す、という方法です。

肝はデコーダーを実装している部分です。

```swift
    /// デコード
    /// - Parameter decoder: デコーダー
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        let type = try values.decodeIfPresent(`Type`.self, forKey: .type)
        switch type {
        case .video:
            self = .video(try Video(from: decoder))
        case .book:
            self = .book(try Book(from: decoder))
        case .none:
            let error = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unknown type")
            throw DecodingError.typeMismatch(Embed.self, error)
        }
    }
```

[`init(from decoder: Decoder)`](https://developer.apple.com/documentation/swift/decodable/init(from:))は[`Decodable`](https://developer.apple.com/documentation/swift/decodable)で指定されているメソッドです。

[`String`](https://developer.apple.com/documentation/swift/string/init(from:)-qki5)や[`Int`](https://developer.apple.com/documentation/swift/int/init(from:)-5ru5)などはあらかじめ`Decodable`に適合しており、それらの型を使うだけの場合はデコーダーを実装する必要はありません。

ただし今回は、値の内容によって型を振り分ける必要があるため、デコーダを実装しています。

```swift
        let values = try decoder.container(keyedBy: CodingKeys.self)
        let type = try values.decodeIfPresent(`Type`.self, forKey: .type)
```

こちらの部分でとりあえず、`type`だけパースします。

[`decodeIfPresent`](https://developer.apple.com/documentation/swift/keyeddecodingcontainer/decodeifpresent(_:forkey:)-91iaz)はデコードが成功したのみだけ値を返すメソッドで、将来的に`music`や`image`などのタイプが増えた場合に備えています。

その後タイプによって`Embed`の値を決定します。

```swift
        case .video:
            self = .video(try Video(from: decoder))
```

`Video`は`Decodable`にすでに適合しているため、デコーダーを渡すことで初期化できます。

------

場合によってはタイプがない、なんてこともあるかもしれません。

```json
{
    "data": [
        {
            "id": 1,
            "embed": {
                "url": "./xxx.mp4",
                "duration": 120
            }
        },
        {
            "id": 2,
            "embed": {
                "url": "./xxx.pdf",
                "page": 300
            }
        }
    ]
}
```

こういった場合も対応できます。

```swift
    /// デコード
    /// - Parameter decoder: デコーダー
    init(from decoder: Decoder) throws {
        if let video = try? Video(from: decoder) {
            self = .video(video)
        } else if let book = try? Book(from: decoder) {
            self = .book(book)
        } else {
            let error = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unknown type")
            throw DecodingError.typeMismatch(Embed.self, error)
        }
    }
```

型に対して随時初期化を試みることでパースするという方法です。

ただ、これに関してはJSONのフォーマットを変えた方が良いかもしれません・・・。

------

型の違いを`enum`で吸収するというテクニックは、Appleが提供している[MusicKit](https://developer.apple.com/musickit/)から知りました。

具体的には[`Track`](https://developer.apple.com/documentation/musickit/track)という部分です。

こちらは音楽ファイルとミュージックビデオを収める型になります。

ミュージックアプリのプレイリスト内はどちらも混在できるようになっており、配列としてトラック部分が渡されるためにこういった型になっています。

実際のAPIは以下のようになっており、そこをパースするためにこのテクニックが使われています。

> data (Required) The ordered songs and music videos in the tracklist of the playlist.
> [*]  Possible types: MusicVideos, Songs
>
> [Playlists.Relationships.PlaylistsTracksRelationship | Apple Developer Documentation](https://developer.apple.com/documentation/applemusicapi/playlists/relationships/playliststracksrelationship)

------

`Codable`はかなり柔軟にできており、どんなJSONであれ、パースできなかった経験は今のところありません。

Swiftの強みを活かす意味でも、`Codable`の理解は深めていきたいと思います。