この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
概要
大阪オフィスの山田です。この前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 & Encodable
がCodable
なので、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!")
おわりに
毎年夏になると山の方まで、ヒグラシの鳴き声が聞きに行っています。カナカナ。