【Swift】Codableで動的なキーを持つJSONに対応する

Swiftで動的なキーを持つJSONに対応できるCodableなモデルの実装方法について調べてみました。
2020.07.15

概要

大阪オフィスの山田です。この前APIClientを実装する記事を書きましたが、その中で、キーが動的に変わるJSONと遭遇しました。その際、Codableを使うにはどう実装したら良いか調べたので、記事にします。

開発環境

  • macOS: 10.15.4
  • Xcode: 11.5

キーが動的に変わるJSONの例

以前の記事にも登場した、Gistに投稿するGitHub APIを例にあげます。 以下のbodyでPOSTします。

{
    "public": true,
    "files": {
        "gist.txt": {
            "content": "Toukou!"
        }
    }
}

この中でgist.txtはGistに投稿するファイル名であり、登録したいファイル名によってキーの値が変わります。APIのドキュメントはこちら

モデルのプロパティを定義する

APIの仕様では、filesは複数指定できますが、今回は簡略化のため、1つのみ指定するようにします。

struct PostGist {
    let `public`: Bool
    let fileName: String
    let content: String
}

publicは予約語なのでバッククォートで囲んでいます。 JSON形式に変換したいので、Encodableを継承させます。

自由に生成できるCodingKeyを定義する

以下のようにEncodableを継承します。そして、keyを外部から受け付けられるようにCodingKeyを継承したCustomCodingKeyを定義しています。CustomCodingKeyの中で動的に変化しないプロパティ(publicやfilesなど)は利便性のためにstaticで宣言しています。

extension PostGist: Encodable {
    private struct CustomCodingKey: CodingKey {
        var stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
        }

        var intValue: Int?
        init?(intValue: Int) { return nil }

        static let `public` = CustomCodingKey(stringValue: "public")!
        static let files = CustomCodingKey(stringValue: "files")!
        static let content = CustomCodingKey(stringValue: "content")!
    }
}

intValueも設定できるようですが、int値をキーにするパターンが思いつかなかったので今回はnilを返却するようにしています。

次にencodeメソッドを実装します。

extension PostGist: Encodable {

    // ...省略...

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CustomCodingKey.self)
        try container.encode(`public`, forKey: .public)
        var filesContainer = container.nestedContainer(keyedBy: CustomCodingKey.self, forKey: .files)
        let fileNameKey = CustomCodingKey(stringValue: fileName)!
        var fileContainer = filesContainer.nestedContainer(keyedBy: CustomCodingKey.self, forKey: fileNameKey)
        try fileContainer.encode(content, forKey: .content)
    }
}

基本的にはencodeメソッドを実装するのとかわり映えしませんが、JSONで指定するファイル名の部分はfileNameプロパティを使ってCustomCodingKeyを生成し、それを使ってnestedContainerメソッドを使ってcontainer(KeyedEncodingContainer)を取得しています。そのcontainerの中にcontentが入るようにしています。

実際にエンコードして確認する

実際にエンコードしてみて動作を確認します。

let gist = PostGist(public: false, fileName: "gist.txt", content: "Toukou!")
let data = try! JSONEncoder().encode(gist)
print(String(data: data, encoding: String.Encoding.utf8)!)

すると以下のようにコンソールに出力されます。

{"public":false,"files":{"gist.txt":{"content":"Toukou!"}}}

読みやすく整えると以下のようになります。

{
    "public": false,
    "files": {
        "gist.txt": {
            "content": "Toukou!"
        }
    }
}

Decodableを継承してデコードできるようにする

Decodable & EncodableCodableなので、Codableを継承して、init(from decoder: Decoder) throwsメソッドを実装します。

extension PostGist: Codable {
  // ...省略...
      init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CustomCodingKey.self)
        `public` = try container.decode(Bool.self, forKey: .public)
        let filesContainer = try container.nestedContainer(keyedBy: CustomCodingKey.self, forKey: .files)
        fileName = filesContainer.allKeys.first!.stringValue
        let fileContainer = try filesContainer.nestedContainer(keyedBy: CustomCodingKey.self, forKey: CustomCodingKey(stringValue: fileName)!)
        content = try fileContainer.decode(String.self, forKey: .content)
    }
  // ...省略...
}

JSONのfilesの中にあるキーの値をfileNameプロパティの値として使うようにしています。 複数ファイルが存在する場合に対応させる場合はfor key in fileContainer.allKeysのように全てのキーを参照することで実現可能です。

実際にデコードして確認する

実際にデコードして動作を確認します。

let json = """
    {
        "public": false,
        "files": {
            "gist.txt": {
                "content": "Toukou!"
            }
        }
    }
"""

let decoder = JSONDecoder()
if let jsonData = json.data(using: .utf8) {
  do {
      let results = try decoder.decode(PostGist.self, from: jsonData)
      print(results)
  } catch {
      print(error)
  }
}

コンソールに以下のように表示されて、インスタンスが生成されていることがわかります。

PostGist(public: false, fileName: "gist.txt", content: "Toukou!")

おわりに

毎年夏になると山の方まで、ヒグラシの鳴き声が聞きに行っています。カナカナ。

参考