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()
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
する方法もあるようだし、どっちがより良いのかはわからないけど、とりあえず汎用的に使いまわせるようにできたので悪くない気がしている。
参考