イケてない 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
する方法もあるようだし、どっちがより良いのかはわからないけど、とりあえず汎用的に使いまわせるようにできたので悪くない気がしている。
参考
- Swift 4 JSON Decodable simplest way to decode type change - Stack Overflow
- Encoding and Decoding Custom Types | Apple Developer Documentation
- Swift 4 Decodable: Beyond The Basics – Swiftly Swift – Medium
- Codableで色々なJSONに対応する - Qiita
- ルートが配列のJSONをCoadableでカスタムモデルにマッピングする - Qiita
- JSONDecoderのちょっぴり痒い所 | Tomorrow Never Comes.
- DecodableのDecodeを簡潔に書きたい - will and way