🐥
[Swift] Codable で特定のプロパティを共通化しつつデコードする方法
概要
どういうことかと言うと、esa.io のクライアントアプリを作っているときにぶち当たった壁です。
esa.io のAPIで、配列を返すエンドポイントは基本的にページネーションに必要なプロパティが含まれています。
以下のようなレスポンスが返ってきます。
# GET /v1/teams HTTP/1.1
{
"teams": [
...
],
"prev_page": null,
"next_page": 2,
"total_count": 30,
"page": 1,
"per_page": 20,
"max_per_page": 100
}
こんな感じで、配列のプロパティ + ページネーションに関するプロパティ群みたいな構成になっています。
他のエンドポイント用のエンティティの宣言で、意味合いも名前も全く同じプロパティの宣言をするのは骨が折れますし冗長ですよね。
今回はこのページネーション部分を共通化する方法を見つけたので、備忘録として書きます。
実装方針
- Pagination モデルを作成する
- 配列を返すエンドポイントのエンティティの作成 (本記事では例として Teams)
- Teams に
@dynamicMemberLookup
を付与して、本来のレスポンスと同じ使い心地で書けるようにする - Teams の
Decodable
のinit(from:)
を定義
やっていく
-
import Foundation struct Pagination: Codable { let previousPage: Int? let nextPage: Int? let totalCount: Int let page: Int let perPage: Int let maxPerPage: Int enum CodingKeys: String, CodingKey { case previousPage = "prev_page" case nextPage case totalCount case page case perPage case maxPerPage } }
-
- Teams モデルの配列の一要素になるやつです
import Foundation struct Team: Codable { enum PrivacyScope: String, Codable { case open case closed } let name: String let privacy: Team.PrivacyScope let description: String let icon: URL let url: URL }
-
-
Pagination
にぶら下がるプロパティに直接アクセスできるようにする方針のため、pagination
プロパティはprivate
にします。 - が、モックデータを作りやすくするため、一応イニシャライザは用意します
import Foundation struct Teams: Codable { private let pagination: Pagination let teams: [Team] init(teams: [Team], pagination: Pagination) { self.teams = teams self.pagination = pagination } }
-
-
@dynamicMemberLookup
を付与して、本来のレスポンスと同じ使い心地で書けるようにするimport Foundation @dynamicMemberLookup struct Teams: Codable { private let pagination: Pagination let teams: [Team] init(teams: [Team], pagination: Pagination) { self.teams = teams self.pagination = pagination } subscript<T>(dynamicMember keyPath: KeyPath<Pagination, T>) -> T { pagination[keyPath: keyPath] } }
-
init(from:)
を定義- ここが一番の目玉だと思います
- Xcode のコードスニペットで
let container = try decoder.container(keyedBy: CodingKeys.self)
が自動で定義される都合上、これを使いたくなりますが、実は使わないんですよね...。ここが結構詰まりどころでした -
Pagination
の.init(from:)
を呼び出してpagination
プロパティを初期化する形になります
import Foundation @dynamicMemberLookup struct Teams: Codable { private let pagination: Pagination let teams: [Team] init(teams: [Team], pagination: Pagination) { self.teams = teams self.pagination = pagination } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) // MARK: container を使わない点がミソ self.pagination = try Pagination(from: decoder) self.teams = try container.decode([Team].self, forKey: .teams) } subscript<T>(dynamicMember keyPath: KeyPath<Pagination, T>) -> T { pagination[keyPath: keyPath] } }
完成
- 出来上がったコードが以下です
import Foundation
struct Pagination: Codable {
let previousPage: Int?
let nextPage: Int?
let totalCount: Int
let page: Int
let perPage: Int
let maxPerPage: Int
enum CodingKeys: String, CodingKey {
case previousPage = "prev_page"
case nextPage
case totalCount
case page
case perPage
case maxPerPage
}
}
struct Team: Codable {
enum PrivacyScope: String, Codable {
case open
case closed
}
let name: String
let privacy: Team.PrivacyScope
let description: String
let icon: URL
let url: URL
}
@dynamicMemberLookup
struct Teams: Codable {
private let pagination: Pagination
let teams: [Team]
subscript<T>(dynamicMember keyPath: KeyPath<Pagination, T>) -> T {
pagination[keyPath: keyPath]
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.pagination = try Pagination(from: decoder)
self.teams = try container.decode([Team].self, forKey: .teams)
}
init(teams: [Team], pagination: Pagination) {
self.teams = teams
self.pagination = pagination
}
}
Playground で動かしてみる
- 以下のコードを Xcode Playgrounds に貼り付けてお試しいただけます。
import Foundation
struct Pagination: Codable {
let previousPage: Int?
let nextPage: Int?
let totalCount: Int
let page: Int
let perPage: Int
let maxPerPage: Int
enum CodingKeys: String, CodingKey {
case previousPage = "prev_page"
case nextPage
case totalCount
case page
case perPage
case maxPerPage
}
}
struct Team: Codable {
enum PrivacyScope: String, Codable {
case open
case closed
}
let name: String
let privacy: Team.PrivacyScope
let description: String
let icon: URL
let url: URL
}
@dynamicMemberLookup
struct Teams: Codable {
private let pagination: Pagination
let teams: [Team]
subscript<T>(dynamicMember keyPath: KeyPath<Pagination, T>) -> T {
pagination[keyPath: keyPath]
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.pagination = try Pagination(from: decoder)
self.teams = try container.decode([Team].self, forKey: .teams)
}
init(teams: [Team], pagination: Pagination) {
self.teams = teams
self.pagination = pagination
}
}
let teamsReponse: Data = """
{
"teams": [
{
"name": "docs",
"privacy": "open",
"description": "esa.io official documents",
"icon": "https://img.esa.io/uploads/production/teams/105/icon/thumb_m_0537ab827c4b0c18b60af6cdd94f239c.png",
"url": "https://docs.esa.io/"
}
],
"prev_page": null,
"next_page": null,
"total_count": 1,
"page": 1,
"per_page": 20,
"max_per_page": 100
}
""".data(using: .utf8)!
let decoder: JSONDecoder = .init()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let teams: Teams = try decoder.decode(Teams.self, from: teamsReponse)
print(teams)
実行結果がこんな感じ
無事、teams.maxPerPage
みたいな書き方ができるようになりました
余談
去年の頭ぐらいに esa クライアントを作ってみたくなって Swift を触り始めたときから、ずっとこのページネーションの共通化のやり方が気になっていたのでようやく解決できて本当に嬉しい限りでした☺️
それまで TypeScript 中心に触っていたのですが、TypeScript だと、
type Team = {
name: string
privacy: "open" | "closed"
description: string
icon: string
url: string
}
type Pagination = {
previousPage?: number
nextPage?: number
totalCount: number
page: number
perPage: number
maxPerPage: number
}
type Teams = {
teams: Team[]
} & Pagination
const teams: Teams = {
teams: [
{
name: "esa"
...
}
],
previousPage: undefined,
...
}
みたいな感じで、Intersection Type
と呼ばれる機能で型の合成ができていたので、Swift で同等のことができないと分かったときは戸惑いました😇
(たしかに Swift にも typealias
はありますが、これと全く同じことができるかというとそうじゃないんですよねぇ...)
Discussion